1039 lines
43 KiB
Dart
1039 lines
43 KiB
Dart
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;
|
||
bool _salesEntryDefaultCashMode = false;
|
||
bool _salesEntryShowGross = true;
|
||
|
||
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();
|
||
final defaultCash = await _appSettingsRepo.getSalesEntryCashModeDefault();
|
||
final showGross = await _appSettingsRepo.getSalesEntryShowGross();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_grossProfitEnabled = enabled;
|
||
_grossProfitToggleVisible = toggleVisible;
|
||
_grossProfitIncludeProvisional = includeProvisional;
|
||
_salesEntryDefaultCashMode = defaultCash;
|
||
_salesEntryShowGross = showGross;
|
||
});
|
||
}
|
||
|
||
Future<void> _setSalesEntryDefaultCashMode(bool value) async {
|
||
setState(() => _salesEntryDefaultCashMode = value);
|
||
await _appSettingsRepo.setSalesEntryCashModeDefault(value);
|
||
}
|
||
|
||
Future<void> _setSalesEntryShowGross(bool value) async {
|
||
setState(() => _salesEntryShowGross = value);
|
||
await _appSettingsRepo.setSalesEntryShowGross(value);
|
||
}
|
||
|
||
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: 'U2エディタ表示モード',
|
||
subtitle: '長押しドロワーや初期表示状態をここで統一できます',
|
||
child: Column(
|
||
children: [
|
||
SwitchListTile.adaptive(
|
||
title: const Text('新規伝票を現金売上モードで開始'),
|
||
subtitle: const Text('顧客未選択で「現金売上」名義を自動入力します'),
|
||
value: _salesEntryDefaultCashMode,
|
||
onChanged: _setSalesEntryDefaultCashMode,
|
||
),
|
||
SwitchListTile.adaptive(
|
||
title: const Text('新規伝票で粗利を初期表示'),
|
||
subtitle: const Text('U2/A1を開いた直後から粗利メタ情報を表示します'),
|
||
value: _salesEntryShowGross,
|
||
onChanged: _setSalesEntryShowGross,
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'U2のタイトルを長押しすると現場向けのクイックドロワーが開き、これらの設定を一時的に切り替えられます。',
|
||
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,
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|