chore: flesh out settings template and lock badges

This commit is contained in:
joe 2026-02-25 19:53:33 +09:00
parent 60fc0b46ac
commit bab533fccb
3 changed files with 237 additions and 96 deletions

View file

@ -150,6 +150,8 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
final themeColor = isDraft ? Colors.blueGrey.shade800 : Colors.white; final themeColor = isDraft ? Colors.blueGrey.shade800 : Colors.white;
final textColor = isDraft ? Colors.white : Colors.black87; final textColor = isDraft ? Colors.white : Colors.black87;
final locked = _currentInvoice.isLocked;
return Scaffold( return Scaffold(
backgroundColor: themeColor, backgroundColor: themeColor,
appBar: AppBar( appBar: AppBar(
@ -157,6 +159,15 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
title: Text(isDraft ? "伝票詳細 (下書き)" : "販売アシスト1号 伝票詳細"), title: Text(isDraft ? "伝票詳細 (下書き)" : "販売アシスト1号 伝票詳細"),
backgroundColor: isDraft ? Colors.black87 : Colors.blueGrey, backgroundColor: isDraft ? Colors.black87 : Colors.blueGrey,
actions: [ actions: [
if (locked)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
child: Chip(
label: const Text("ロック中", style: TextStyle(color: Colors.white)),
avatar: const Icon(Icons.lock, size: 16, color: Colors.white),
backgroundColor: Colors.redAccent,
),
),
if (isDraft && !_isEditing) if (isDraft && !_isEditing)
TextButton.icon( TextButton.icon(
icon: const Icon(Icons.check_circle_outline, color: Colors.orangeAccent), icon: const Icon(Icons.check_circle_outline, color: Colors.orangeAccent),
@ -165,50 +176,42 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
), ),
if (!_isEditing) ...[ if (!_isEditing) ...[
IconButton(icon: const Icon(Icons.grid_on), onPressed: _exportCsv, tooltip: "CSV出力"), IconButton(icon: const Icon(Icons.grid_on), onPressed: _exportCsv, tooltip: "CSV出力"),
if (widget.isUnlocked) if (widget.isUnlocked && !locked)
IconButton( IconButton(
icon: const Icon(Icons.copy), icon: const Icon(Icons.copy),
tooltip: "コピーして新規作成", tooltip: "コピーして新規作成",
onPressed: () async { onPressed: () async {
// IDを生成して複製
final newId = DateTime.now().millisecondsSinceEpoch.toString(); final newId = DateTime.now().millisecondsSinceEpoch.toString();
final duplicateInvoice = _currentInvoice.copyWith( final duplicateInvoice = _currentInvoice.copyWith(
id: newId, id: newId,
date: DateTime.now(), date: DateTime.now(),
isDraft: true, // isDraft: true,
); );
await Navigator.push( await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => InvoiceInputForm( builder: (context) => InvoiceInputForm(
onInvoiceGenerated: (inv, path) { onInvoiceGenerated: (inv, path) {},
//
// HistoryScreen側で対応済み
},
existingInvoice: duplicateInvoice, existingInvoice: duplicateInvoice,
), ),
), ),
); );
}, },
), ),
if (widget.isUnlocked) if (widget.isUnlocked && !locked)
IconButton( IconButton(
icon: const Icon(Icons.edit_note), // icon: const Icon(Icons.edit_note),
tooltip: "詳細編集", tooltip: "詳細編集",
onPressed: () async { onPressed: () async {
await Navigator.push( await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => InvoiceInputForm( builder: (context) => InvoiceInputForm(
onInvoiceGenerated: (inv, path) { onInvoiceGenerated: (inv, path) {},
//
},
existingInvoice: _currentInvoice, existingInvoice: _currentInvoice,
), ),
), ),
); );
//
final repo = InvoiceRepository(); final repo = InvoiceRepository();
final customerRepo = CustomerRepository(); final customerRepo = CustomerRepository();
final customers = await customerRepo.getAllCustomers(); final customers = await customerRepo.getAllCustomers();

View file

@ -297,6 +297,10 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
backgroundColor: invoice.isDraft backgroundColor: invoice.isDraft
? Colors.orange.shade100 ? Colors.orange.shade100
: (_isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200), : (_isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200),
child: Stack(
children: [
Align(
alignment: Alignment.center,
child: Icon( child: Icon(
invoice.isDraft ? Icons.edit_note : Icons.description_outlined, invoice.isDraft ? Icons.edit_note : Icons.description_outlined,
color: invoice.isDraft color: invoice.isDraft
@ -304,10 +308,15 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
: (_isUnlocked ? Colors.indigo : Colors.grey), : (_isUnlocked ? Colors.indigo : Colors.grey),
), ),
), ),
if (invoice.isLocked)
const Align(alignment: Alignment.bottomRight, child: Icon(Icons.lock, size: 14, color: Colors.redAccent)),
],
),
),
title: Column( title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(invoice.customerNameForDisplay, style: const TextStyle(fontWeight: FontWeight.bold)), Text(invoice.customerNameForDisplay, style: TextStyle(fontWeight: FontWeight.bold, color: invoice.isLocked ? Colors.grey : Colors.black87)),
if (invoice.subject?.isNotEmpty ?? false) if (invoice.subject?.isNotEmpty ?? false)
Text( Text(
invoice.subject!, invoice.subject!,
@ -343,6 +352,10 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
_loadData(); _loadData();
}, },
onLongPress: () async { onLongPress: () async {
if (invoice.isLocked) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("ロック中の伝票は削除できません")));
return;
}
if (!_isUnlocked) { if (!_isUnlocked) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("削除するにはアンロックが必要です")), const SnackBar(content: Text("削除するにはアンロックが必要です")),

View file

@ -1,103 +1,228 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'company_info_screen.dart'; import 'company_info_screen.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
void _showPlaceholder(BuildContext context, String title) { @override
ScaffoldMessenger.of(context).showSnackBar( State<SettingsScreen> createState() => _SettingsScreenState();
SnackBar(content: Text('$title の設定は後で追加してください')),
);
} }
void _showThemePicker(BuildContext context) { class _SettingsScreenState extends State<SettingsScreen> {
showModalBottomSheet( // Company
context: context, final _companyNameCtrl = TextEditingController();
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), final _companyZipCtrl = TextEditingController();
builder: (context) => Padding( final _companyAddrCtrl = TextEditingController();
padding: const EdgeInsets.all(16), final _companyTelCtrl = TextEditingController();
child: Column( final _companyRegCtrl = TextEditingController();
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, // Staff
children: [ final _staffNameCtrl = TextEditingController();
const Text('テーマ選択', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), final _staffMailCtrl = TextEditingController();
const SizedBox(height: 12),
ListTile( // SMTP
leading: const Icon(Icons.brightness_5), final _smtpHostCtrl = TextEditingController();
title: const Text('ライト'), final _smtpPortCtrl = TextEditingController(text: '587');
onTap: () { final _smtpUserCtrl = TextEditingController();
Navigator.pop(context); final _smtpPassCtrl = TextEditingController();
_showPlaceholder(context, 'ライトテーマ適用'); bool _smtpTls = true;
},
), // Backup
ListTile( final _backupPathCtrl = TextEditingController();
leading: const Icon(Icons.brightness_3),
title: const Text('ダーク'), String _theme = 'system';
onTap: () {
Navigator.pop(context); @override
_showPlaceholder(context, 'ダークテーマ適用'); void dispose() {
}, _companyNameCtrl.dispose();
), _companyZipCtrl.dispose();
ListTile( _companyAddrCtrl.dispose();
leading: const Icon(Icons.brightness_auto), _companyTelCtrl.dispose();
title: const Text('システムに従う'), _companyRegCtrl.dispose();
onTap: () { _staffNameCtrl.dispose();
Navigator.pop(context); _staffMailCtrl.dispose();
_showPlaceholder(context, 'システムテーマ適用'); _smtpHostCtrl.dispose();
}, _smtpPortCtrl.dispose();
), _smtpUserCtrl.dispose();
], _smtpPassCtrl.dispose();
), _backupPathCtrl.dispose();
), super.dispose();
);
} }
void _showSnackbar(String msg) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
void _saveCompany() => _showSnackbar('自社情報を保存(テンプレ)');
void _saveStaff() => _showSnackbar('担当者情報を保存(テンプレ)');
void _saveSmtp() => _showSnackbar('SMTP設定を保存テンプレ');
void _saveBackup() => _showSnackbar('バックアップ設定を保存(テンプレ)');
void _pickBackupPath() => _showSnackbar('バックアップ先の選択は後で実装');
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('設定'), title: const Text('設定'),
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () => _showSnackbar('設定はテンプレ実装です。実際の保存は未実装'),
)
],
), ),
body: ListView( body: ListView(
padding: const EdgeInsets.all(16),
children: [ children: [
ListTile( _section(
leading: const Icon(Icons.business), title: '自社情報',
title: const Text('自社情報'), subtitle: '会社名・住所・登録番号など',
subtitle: const Text('会社名・住所・登録番号など'), child: Column(
onTap: () async { 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: _companyRegCtrl, decoration: const InputDecoration(labelText: '登録番号 (インボイス)')),
const SizedBox(height: 8),
Row(
children: [
OutlinedButton.icon(
icon: const Icon(Icons.upload_file),
label: const Text('画面で編集'),
onPressed: () async {
await Navigator.push(context, MaterialPageRoute(builder: (context) => const CompanyInfoScreen())); await Navigator.push(context, MaterialPageRoute(builder: (context) => const CompanyInfoScreen()));
}, },
), ),
const Divider(height: 1), const SizedBox(width: 8),
ListTile( ElevatedButton.icon(
leading: const Icon(Icons.badge_outlined), icon: const Icon(Icons.save),
title: const Text('担当者情報'), label: const Text('保存'),
subtitle: const Text('自社担当者の署名・連絡先'), onPressed: _saveCompany,
onTap: () => _showPlaceholder(context, '担当者情報'),
), ),
const Divider(height: 1), ],
ListTile(
leading: const Icon(Icons.email_outlined),
title: const Text('SMTP情報'),
subtitle: const Text('メール送信サーバ設定'),
onTap: () => _showPlaceholder(context, 'SMTP情報'),
), ),
const Divider(height: 1), ],
ListTile( ),
leading: const Icon(Icons.cloud_upload_outlined), ),
title: const Text('バックアップドライブ'), _section(
subtitle: const Text('バックアップ先のクラウド/ローカルドライブ'), title: '担当者情報',
onTap: () => _showPlaceholder(context, 'バックアップドライブ'), subtitle: '署名や連絡先(送信者情報)',
child: Column(
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),
SwitchListTile(
title: const Text('STARTTLS を使用'),
value: _smtpTls,
onChanged: (v) => setState(() => _smtpTls = v),
),
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: _saveSmtp,
),
],
),
),
_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(
children: [
RadioListTile<String>(
value: 'light',
groupValue: _theme,
title: const Text('ライト'),
onChanged: (v) => setState(() => _theme = v ?? 'light'),
),
RadioListTile<String>(
value: 'dark',
groupValue: _theme,
title: const Text('ダーク'),
onChanged: (v) => setState(() => _theme = v ?? 'dark'),
),
RadioListTile<String>(
value: 'system',
groupValue: _theme,
title: const Text('システムに従う'),
onChanged: (v) => setState(() => _theme = v ?? 'system'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: () => _showSnackbar('テーマ設定を保存(テンプレ): $_theme'),
),
],
), ),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.palette_outlined),
title: const Text('テーマ選択'),
subtitle: const Text('配色や見た目を切り替え'),
onTap: () => _showThemePicker(context),
), ),
], ],
), ),
); );
} }
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,
],
),
),
);
}
} }