h-1.flutter.0/lib/screens/settings_screen.dart
2026-03-04 14:55:40 +09:00

998 lines
41 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:googleapis/calendar/v3.dart' as gcal;
import 'package:image_picker/image_picker.dart';
import '../services/app_settings_repository.dart';
import '../services/calendar_sync_diagnostics.dart';
import '../services/calendar_sync_service.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';
import 'dashboard_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _CalendarOption {
const _CalendarOption({required this.id, required this.summary, this.detail});
final String id;
final String summary;
final String? detail;
}
// シンプルなアイコンマップ(拡張可)
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> {
final _appSettingsRepo = AppSettingsRepository();
final _calendarSyncService = CalendarSyncService();
final _calendarDiagnostics = CalendarSyncDiagnostics();
// External sync (母艦システム「お局様」連携)
final _externalHostCtrl = TextEditingController();
final _externalPassCtrl = TextEditingController();
// Backup
final _backupPathCtrl = TextEditingController();
String _theme = 'system';
// Kana map (kanji -> kana head)
Map<String, String> _customKanaMap = {};
final _kanaKeyCtrl = TextEditingController();
final _kanaValCtrl = TextEditingController();
// Dashboard / Home
bool _homeDashboard = false;
bool _statusEnabled = true;
final _statusTextCtrl = TextEditingController(text: '工事中');
List<DashboardMenuItem> _menuItems = [];
bool _loadingAppSettings = true;
bool _forceDashboardOnExit = false;
// Google Calendar
bool _calendarEnabled = false;
bool _calendarBusy = false;
bool _loadingCalendars = false;
bool _calendarSyncing = false;
String? _googleAccountEmail;
String? _selectedCalendarId;
List<_CalendarOption> _availableCalendars = [];
String? _lastCalendarSyncStatus;
bool get _supportsCalendarSync => !kIsWeb && (Platform.isAndroid || Platform.isIOS);
// Gross profit / sales entry options
bool _grossProfitEnabled = true;
bool _grossProfitToggleVisible = true;
bool _grossProfitIncludeProvisional = false;
static const _kExternalHost = 'external_host';
static const _kExternalPass = 'external_pass';
static const _kBackupPath = 'backup_path';
@override
void dispose() {
_externalHostCtrl.dispose();
_externalPassCtrl.dispose();
_backupPathCtrl.dispose();
_kanaKeyCtrl.dispose();
_kanaValCtrl.dispose();
_statusTextCtrl.dispose();
super.dispose();
}
Future<void> _loadAll() async {
await _loadKanaMap();
final externalHost = await _appSettingsRepo.getString(_kExternalHost) ?? '';
final externalPass = await _appSettingsRepo.getString(_kExternalPass) ?? '';
final backupPath = await _appSettingsRepo.getString(_kBackupPath) ?? '';
final theme = await _appSettingsRepo.getTheme();
setState(() {
_externalHostCtrl.text = externalHost;
_externalPassCtrl.text = externalPass;
_backupPathCtrl.text = backupPath;
_theme = theme;
});
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;
});
await _loadCalendarSettings();
await _loadGrossProfitSettings();
}
@override
void initState() {
super.initState();
_loadAll();
}
void _showSnackbar(String msg) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
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('ホーム/ダッシュボード設定を保存しました');
setState(() {
_forceDashboardOnExit = _homeDashboard;
});
}
Future<void> _persistMenu() async {
await _appSettingsRepo.setDashboardMenu(_menuItems);
}
void _handleExit() {
if (!mounted) return;
if (_forceDashboardOnExit) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const DashboardScreen()),
(route) => false,
);
} else {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
}
}
void _addMenuItem() async {
final titleCtrl = TextEditingController();
String route = 'invoice_history';
final iconCtrl = TextEditingController(text: 'list_alt');
String? customIconPath;
await showDialog(
context: context,
builder: (ctx) => AnimatedPadding(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
child: 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: 'sales_operations', child: Text('B1:販売オペレーション')),
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 'sales_operations':
return 'B1:販売オペレーション';
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 {
await _appSettingsRepo.setString(_kExternalHost, _externalHostCtrl.text);
await _appSettingsRepo.setString(_kExternalPass, _externalPassCtrl.text);
_showSnackbar('外部同期設定を保存しました');
}
Future<void> _saveBackup() async {
await _appSettingsRepo.setString(_kBackupPath, _backupPathCtrl.text);
_showSnackbar('バックアップ設定を保存しました');
}
void _pickBackupPath() => _showSnackbar('バックアップ先の選択は後で実装');
Future<void> _setGrossProfitEnabled(bool value) async {
setState(() => _grossProfitEnabled = value);
await _appSettingsRepo.setGrossProfitEnabled(value);
}
Future<void> _setGrossProfitToggleVisible(bool value) async {
setState(() => _grossProfitToggleVisible = value);
await _appSettingsRepo.setGrossProfitToggleVisible(value);
}
Future<void> _setGrossProfitIncludeProvisional(bool value) async {
setState(() => _grossProfitIncludeProvisional = value);
await _appSettingsRepo.setGrossProfitIncludeProvisional(value);
}
Future<void> _loadKanaMap() async {
final json = await _appSettingsRepo.getString('customKanaMap');
if (json != null && json.isNotEmpty) {
try {
final Map<String, dynamic> decoded = jsonDecode(json);
setState(() => _customKanaMap = decoded.map((k, v) => MapEntry(k, v.toString())));
} catch (_) {
// ignore
}
}
}
Future<void> _saveKanaMap() async {
await _appSettingsRepo.setString('customKanaMap', jsonEncode(_customKanaMap));
_showSnackbar('かなインデックスを保存しました');
}
Future<void> _loadCalendarSettings() async {
final enabled = await _appSettingsRepo.getGoogleCalendarEnabled();
final calendarId = await _appSettingsRepo.getGoogleCalendarId();
final account = await _appSettingsRepo.getGoogleCalendarAccountEmail();
if (!mounted) return;
setState(() {
_calendarEnabled = enabled;
_selectedCalendarId = calendarId;
_googleAccountEmail = account;
});
}
Future<void> _loadGrossProfitSettings() async {
final enabled = await _appSettingsRepo.getGrossProfitEnabled();
final toggleVisible = await _appSettingsRepo.getGrossProfitToggleVisible();
final includeProvisional = await _appSettingsRepo.getGrossProfitIncludeProvisional();
if (!mounted) return;
setState(() {
_grossProfitEnabled = enabled;
_grossProfitToggleVisible = toggleVisible;
_grossProfitIncludeProvisional = includeProvisional;
});
}
Future<void> _handleCalendarEnabledChanged(bool enabled) async {
if (_calendarBusy) return;
setState(() => _calendarBusy = true);
try {
if (enabled) {
final success = await _calendarSyncService.ensureSignedIn();
if (!success) {
if (!mounted) return;
setState(() {
_calendarEnabled = false;
});
_showSnackbar('Googleサインインに失敗しました');
return;
}
await _appSettingsRepo.setGoogleCalendarEnabled(true);
final email = await _appSettingsRepo.getGoogleCalendarAccountEmail();
if (!mounted) return;
setState(() {
_calendarEnabled = true;
_googleAccountEmail = email;
});
await _refreshCalendarList();
_showSnackbar('Googleカレンダー連携を有効化しました');
} else {
await _calendarSyncService.signOut();
await _appSettingsRepo.setGoogleCalendarEnabled(false);
if (!mounted) return;
setState(() {
_calendarEnabled = false;
_googleAccountEmail = null;
_selectedCalendarId = null;
_availableCalendars = [];
});
_showSnackbar('Googleカレンダー連携を無効化しました');
}
} finally {
if (mounted) {
setState(() => _calendarBusy = false);
}
}
}
Future<void> _refreshCalendarList() async {
if (_loadingCalendars) return;
if (!_calendarEnabled) {
_showSnackbar('まずは連携スイッチをONにしてください');
return;
}
setState(() => _loadingCalendars = true);
try {
final ready = await _calendarSyncService.ensureSignedIn();
if (!ready) {
_showSnackbar('Googleアカウントの認証が必要です');
return;
}
final List<gcal.CalendarListEntry> calendars = await _calendarSyncService.fetchCalendars();
final options = calendars
.where((entry) => (entry.id ?? '').isNotEmpty)
.map((entry) => _CalendarOption(
id: entry.id!,
summary: entry.summary ?? entry.id!,
detail: entry.primary == true ? 'プライマリ' : entry.accessRole,
))
.toList();
final hasPrimary = options.any((o) => o.id == 'primary');
if (!hasPrimary) {
options.insert(0, const _CalendarOption(id: 'primary', summary: 'デフォルト(プライマリ)'));
}
if (!mounted) return;
setState(() {
_availableCalendars = options;
if (_selectedCalendarId == null || !_availableCalendars.any((o) => o.id == _selectedCalendarId)) {
_selectedCalendarId = options.isNotEmpty ? options.first.id : null;
}
});
if (_selectedCalendarId != null) {
await _appSettingsRepo.setGoogleCalendarId(_selectedCalendarId);
}
} catch (e) {
_showSnackbar('カレンダー一覧の取得に失敗しました');
} finally {
if (mounted) {
setState(() => _loadingCalendars = false);
}
}
}
Future<void> _handleCalendarSelection(String? calendarId) async {
if (calendarId == null) return;
setState(() => _selectedCalendarId = calendarId);
await _appSettingsRepo.setGoogleCalendarId(calendarId);
_showSnackbar('同期先カレンダーを保存しました');
}
Future<void> _reauthenticateCalendarAccount() async {
if (_calendarBusy) return;
setState(() => _calendarBusy = true);
try {
final success = await _calendarSyncService.ensureSignedIn();
if (!success) {
_showSnackbar('Googleサインインに失敗しました');
return;
}
final email = await _appSettingsRepo.getGoogleCalendarAccountEmail();
if (!mounted) return;
setState(() => _googleAccountEmail = email);
_showSnackbar('Googleアカウントを更新しました');
} finally {
if (mounted) setState(() => _calendarBusy = false);
}
}
Future<void> _runCalendarDiagnostics() async {
if (_calendarSyncing) return;
if (!_calendarEnabled) {
_showSnackbar('まずはカレンダー連携を有効にしてください');
return;
}
setState(() => _calendarSyncing = true);
try {
final result = await _calendarDiagnostics.runFullSync();
if (!mounted) return;
final now = DateTime.now();
final timestamp =
'${now.year}/${now.month.toString().padLeft(2, '0')}/${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
final summary = '出荷${result.shipmentsSynced}件 / 債権${result.receivablesSynced}';
setState(() {
_lastCalendarSyncStatus = '最終同期: $timestamp ($summary)';
});
if (result.hasErrors) {
_showSnackbar('同期は完了しましたが一部でエラーが発生しました');
await showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('同期時のエラー'),
content: Text(result.errors.join('\n')),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('OK')),
],
),
);
} else {
_showSnackbar('Googleカレンダー同期を実行しました ($summary)');
}
} catch (e) {
debugPrint('Manual calendar sync failed: $e');
if (mounted) {
_showSnackbar('Googleカレンダー同期に失敗しました: $e');
}
} finally {
if (mounted) {
setState(() => _calendarSyncing = false);
}
}
}
@override
Widget build(BuildContext context) {
final route = ModalRoute.of(context);
final bool isCurrentRoute = route?.isCurrent ?? true;
final bottomInset = isCurrentRoute ? MediaQuery.of(context).viewInsets.bottom : 0.0;
final listBottomPadding = 24 + bottomInset;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
_handleExit();
},
child: Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: const Text('S1:設定'),
backgroundColor: Colors.indigo,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _handleExit,
),
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () => _showSnackbar('設定はテンプレ実装です。実際の保存は未実装'),
),
],
),
body: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.only(bottom: listBottomPadding),
children: [
_section(
title: 'ホームモード / ダッシュボード',
subtitle: 'ダッシュボードをホームにする・ステータス表示・メニュー管理 (設定はDB保存)',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
title: const Text('ホームをダッシュボードにする'),
value: _homeDashboard,
onChanged: _loadingAppSettings ? null : (v) => setState(() => _homeDashboard = v),
),
SwitchListTile(
title: const Text('ステータスを表示する'),
value: _statusEnabled,
onChanged: _loadingAppSettings ? null : (v) => setState(() => _statusEnabled = v),
),
TextField(
controller: _statusTextCtrl,
enabled: !_loadingAppSettings && _statusEnabled,
decoration: const InputDecoration(labelText: 'ステータス文言', hintText: '例: 工事中'),
),
const SizedBox(height: 8),
Row(
children: [
ElevatedButton.icon(
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,
),
),
],
),
),
if (_supportsCalendarSync)
_section(
title: 'Googleカレンダー連携',
subtitle: '出荷追跡や集金・入金予定をGoogleカレンダーへ自動登録',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
title: const Text('Googleカレンダーと連携する'),
value: _calendarEnabled,
onChanged: _calendarBusy ? null : _handleCalendarEnabledChanged,
),
const SizedBox(height: 8),
if (_calendarEnabled)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('接続アカウント'),
subtitle: Text(_googleAccountEmail ?? '未サインイン'),
trailing: _calendarBusy
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: TextButton.icon(
onPressed: () => _handleCalendarEnabledChanged(false),
icon: const Icon(Icons.logout),
label: const Text('切断'),
),
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.refresh),
label: const Text('カレンダー一覧を取得'),
onPressed: _loadingCalendars ? null : _refreshCalendarList,
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.verified_user),
label: const Text('Googleを再認証'),
onPressed: _calendarBusy ? null : _reauthenticateCalendarAccount,
),
),
if (_loadingCalendars)
const Padding(
padding: EdgeInsets.only(left: 12),
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
),
],
),
const SizedBox(height: 12),
if (_availableCalendars.isEmpty)
Text(
'まだ同期先カレンダーが選ばれていません。「カレンダー一覧を取得」を押して選択してください。',
style: Theme.of(context).textTheme.bodySmall,
)
else
DropdownButtonFormField<String>(
key: ValueKey(_selectedCalendarId ?? 'none'),
initialValue: _selectedCalendarId,
decoration: const InputDecoration(labelText: '同期先カレンダー'),
items: _availableCalendars
.map((option) => DropdownMenuItem<String>(
value: option.id,
child: Text(option.detail == null ? option.summary : '${option.summary} (${option.detail})'),
))
.toList(),
onChanged: _loadingCalendars ? null : _handleCalendarSelection,
),
const SizedBox(height: 12),
OutlinedButton.icon(
icon: _calendarSyncing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
label: Text(_calendarSyncing ? '同期を実行中…' : '今すぐカレンダー同期を実行'),
onPressed: _calendarSyncing ? null : _runCalendarDiagnostics,
),
if (_lastCalendarSyncStatus != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_lastCalendarSyncStatus!,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
)
else
Text(
'カレンダー連携を有効化するとGoogleアカウント認証と同期先の選択が行えます。',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
)
else
_section(
title: 'Googleカレンダー連携',
subtitle: 'この設定はAndroid/iOS版のみ対応しています',
child: Text(
'デスクトップ版ではGoogleカレンダー連携を利用できません。モバイル端末から設定してください。',
style: Theme.of(context).textTheme.bodySmall,
),
),
_section(
title: '粗利表示 / 暫定粗利',
subtitle: '売上伝票で卸値にもとづく粗利を表示し、未入荷商品の扱いを制御します',
child: Column(
children: [
SwitchListTile.adaptive(
title: const Text('U2/A1で粗利を表示'),
subtitle: const Text('単価-仕入値を明細ごとに計算して表示します'),
value: _grossProfitEnabled,
onChanged: _setGrossProfitEnabled,
),
SwitchListTile.adaptive(
title: const Text('営業端末に粗利表示スイッチを表示'),
subtitle: const Text('現場ユーザーが粗利の表示/非表示を切り替えられるようにします'),
value: _grossProfitToggleVisible,
onChanged: _setGrossProfitToggleVisible,
),
SwitchListTile.adaptive(
title: const Text('暫定粗利(仕入未確定)を合計に含める'),
subtitle: const Text('仕入値が未登録の明細は粗利=0で仮計上し、合計に含めるかを制御します'),
value: _grossProfitIncludeProvisional,
onChanged: _setGrossProfitIncludeProvisional,
),
const SizedBox(height: 8),
Text(
'仕入値未登録の明細は暫定0円として扱い、仕入確定後に再計算できます。',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
_section(
title: '自社情報',
subtitle: '会社・担当者・振込口座・電話帳取り込み',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('自社/担当者情報、振込口座設定、メールフッタをまとめて編集できます。'),
const SizedBox(height: 12),
Row(
children: [
OutlinedButton.icon(
icon: const Icon(Icons.info_outline),
label: const Text('旧画面 (税率/印影)'),
onPressed: () async {
await Navigator.push(context, MaterialPageRoute(builder: (context) => const CompanyInfoScreen()));
},
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.business),
label: const Text('自社情報ページを開く'),
onPressed: () async {
await Navigator.push(context, MaterialPageRoute(builder: (context) => const BusinessProfileScreen()));
},
),
),
],
),
],
),
),
_section(
title: 'メール設定SM画面へ',
subtitle: 'SMTP・端末メーラー・BCC必須・ログ閲覧など',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('メール送信に関する設定は専用画面でまとめて編集できます。'),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
icon: const Icon(Icons.mail_outline),
label: const Text('メール設定を開く'),
onPressed: () async {
await Navigator.push(
context,
MaterialPageRoute(builder: (context) => const EmailSettingsScreen()),
);
},
),
),
],
),
),
_section(
title: '外部同期(母艦システム「お局様」連携)',
subtitle: '実行ボタンなし。ホストドメインとパスワードを入力してください。',
child: Column(
children: [
TextField(controller: _externalHostCtrl, decoration: const InputDecoration(labelText: 'ホストドメイン')),
TextField(controller: _externalPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true),
const SizedBox(height: 8),
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()));
},
),
],
),
],
),
),
_section(
title: 'バックアップドライブ',
subtitle: 'バックアップ先のクラウド/ローカル',
child: Column(
children: [
TextField(controller: _backupPathCtrl, decoration: const InputDecoration(labelText: '保存先パス/URL')),
const SizedBox(height: 8),
Row(
children: [
OutlinedButton.icon(
icon: const Icon(Icons.folder_open),
label: const Text('参照'),
onPressed: _pickBackupPath,
),
const SizedBox(width: 8),
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: _saveBackup,
),
],
),
],
),
),
_section(
title: 'テーマ選択',
subtitle: '配色や見た目を切り替え(テンプレ)',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
initialValue: _theme,
decoration: const InputDecoration(labelText: 'テーマを選択'),
items: const [
DropdownMenuItem(value: 'light', child: Text('ライト')),
DropdownMenuItem(value: 'dark', child: Text('ダーク')),
DropdownMenuItem(value: 'system', child: Text('システムに従う')),
],
onChanged: (v) => setState(() => _theme = v ?? 'system'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: () async {
await _appSettingsRepo.setTheme(_theme);
await AppThemeController.instance.setTheme(_theme);
if (!mounted) return;
_showSnackbar('テーマ設定を保存しました');
},
),
],
),
),
_section(
title: 'かなインデックス追加',
subtitle: '漢字→行1文字ずつを追加して索引を補強',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _kanaKeyCtrl,
maxLength: 1,
decoration: const InputDecoration(labelText: '漢字1文字', counterText: ''),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _kanaValCtrl,
maxLength: 1,
decoration: const InputDecoration(labelText: '行(例: さ)', counterText: ''),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
final k = _kanaKeyCtrl.text.trim();
final v = _kanaValCtrl.text.trim();
if (k.isEmpty || v.isEmpty) return;
setState(() {
_customKanaMap[k] = v;
});
},
child: const Text('追加'),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
children: _customKanaMap.entries
.map((e) => Chip(
label: Text('${e.key}: ${e.value}'),
onDeleted: () => setState(() => _customKanaMap.remove(e.key)),
))
.toList(),
),
const SizedBox(height: 8),
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: _saveKanaMap,
),
],
),
),
],
),
),
),
);
}
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,
],
),
),
);
}
}