416 lines
No EOL
14 KiB
Text
416 lines
No EOL
14 KiB
Text
// Version: 2.0 - マスター編集用汎用ウィジェット(簡易実装・互換性保持)
|
||
import 'package:flutter/material.dart';
|
||
import 'package:intl/intl.dart';
|
||
import 'master_edit_fields.dart';
|
||
|
||
/// 簡易テキストフィールド(MasterTextField 互換)
|
||
class MasterTextField extends StatelessWidget {
|
||
final String label;
|
||
final String? hint;
|
||
final TextEditingController controller;
|
||
final String? Function(String?)? validator;
|
||
final bool readOnly;
|
||
|
||
const MasterTextField({
|
||
super.key,
|
||
required this.label,
|
||
this.hint,
|
||
required this.controller,
|
||
this.validator,
|
||
this.readOnly = false,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return RichMasterTextField(
|
||
label: label,
|
||
initialValue: controller.text.isEmpty ? null : controller.text,
|
||
hintText: hint ?? '値を入力してください',
|
||
readOnly: readOnly,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 簡易数値フィールド(MasterNumberField 互換)
|
||
class MasterNumberField extends StatelessWidget {
|
||
final String label;
|
||
final String? hint;
|
||
final TextEditingController controller;
|
||
final String? Function(String?)? validator;
|
||
final bool readOnly;
|
||
|
||
const MasterNumberField({
|
||
super.key,
|
||
required this.label,
|
||
this.hint,
|
||
required this.controller,
|
||
this.validator,
|
||
this.readOnly = false,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return RichMasterNumberField(
|
||
label: label,
|
||
initialValue: double.tryParse(controller.text ?? '') != null ? controller.text : null,
|
||
hintText: hint ?? '0.00',
|
||
readOnly: readOnly,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// テキストフィールド(リッチ機能)
|
||
class RichMasterTextField extends StatelessWidget {
|
||
final String label;
|
||
final String? initialValue;
|
||
final String? hintText;
|
||
final bool readOnly;
|
||
|
||
const RichMasterTextField({
|
||
super.key,
|
||
required this.label,
|
||
this.initialValue,
|
||
this.hintText,
|
||
this.readOnly = false,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
label,
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||
),
|
||
),
|
||
if (initialValue != null) ...[
|
||
Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: Colors.green.shade100,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
'入力済',
|
||
style: TextStyle(fontSize: 10, color: Colors.green.shade700),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
TextField(
|
||
controller: TextEditingController(text: initialValue ?? ''),
|
||
decoration: InputDecoration(
|
||
hintText: hintText,
|
||
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey[400]),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8.0),
|
||
borderSide: readOnly ? BorderSide.none : BorderSide(),
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8.0),
|
||
borderSide: BorderSide(color: Theme.of(context).dividerColor),
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8.0),
|
||
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
|
||
),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
|
||
),
|
||
enabled: !readOnly,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 数値フィールド(リッチ機能:自動補完・フォーマット)
|
||
class RichMasterNumberField extends StatelessWidget {
|
||
final String label;
|
||
final double? initialValue;
|
||
final String? hintText;
|
||
final bool readOnly;
|
||
|
||
const RichMasterNumberField({
|
||
super.key,
|
||
required this.label,
|
||
this.initialValue,
|
||
this.hintText,
|
||
this.readOnly = false,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final formatter = NumberFormat('#,##0.00', 'ja_JP');
|
||
String formattedValue = initialValue?.toString() ?? '';
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
label,
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||
),
|
||
),
|
||
if (initialValue != null && initialValue!.isFinite) ...[
|
||
Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: Colors.blue.shade100,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
formatter.format(initialValue!),
|
||
style: TextStyle(fontSize: 10, color: Colors.blue.shade700),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
TextField(
|
||
controller: TextEditingController(text: formattedValue.isEmpty ? null : formattedValue),
|
||
decoration: InputDecoration(
|
||
hintText: hintText,
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8.0),
|
||
),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
|
||
),
|
||
keyboardType: TextInputType.number,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 日付フィールド(リッチ機能:年次表示・カレンダーピッカー)
|
||
class RichMasterDateField extends StatelessWidget {
|
||
final String label;
|
||
final DateTime? initialValue;
|
||
final String? hintText;
|
||
final bool readOnly;
|
||
|
||
const RichMasterDateField({
|
||
super.key,
|
||
required this.label,
|
||
this.initialValue,
|
||
this.hintText,
|
||
this.readOnly = false,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
label,
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||
),
|
||
),
|
||
if (initialValue != null) ...[
|
||
Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: Colors.purple.shade100,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
DateFormat('yyyy/MM/dd').format(initialValue!),
|
||
style: TextStyle(fontSize: 10, color: Colors.purple.shade700),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
TextField(
|
||
readOnly: readOnly,
|
||
decoration: InputDecoration(
|
||
hintText: hintText,
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8.0),
|
||
),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
|
||
suffixIcon: initialValue != null && !readOnly ? IconButton(
|
||
icon: Icon(Icons.calendar_today),
|
||
onPressed: () => _showDatePicker(context),
|
||
) : null,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showDatePicker(BuildContext context) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
builder: (ctx) => CalendarPickerDialog(initialDate: initialValue ?? DateTime.now()),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// カレンダーピッカー(年次表示機能)
|
||
class CalendarPickerDialog extends StatefulWidget {
|
||
final DateTime initialDate;
|
||
|
||
const CalendarPickerDialog({super.key, required this.initialDate});
|
||
|
||
@override
|
||
State<CalendarPickerDialog> createState() => _CalendarPickerDialogState();
|
||
}
|
||
|
||
class _CalendarPickerDialogState extends State<CalendarPickerDialog> {
|
||
int _year = widget.initialDate.year;
|
||
int _month = widget.initialDate.month - 1;
|
||
|
||
DateTime get _selectedDate => DateTime(_year, _month + 1);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return DraggableScrollableSheet(
|
||
initialChildSize: 0.5,
|
||
minChildSize: 0.3,
|
||
maxChildSize: 0.9,
|
||
builder: (context, scrollController) {
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).primaryColor.withOpacity(0.1),
|
||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 4)],
|
||
),
|
||
child: Row(
|
||
children: [
|
||
IconButton(icon: Icon(Icons.chevron_left), onPressed: _changeMonth),
|
||
Text(_formatYear(_year), style: Theme.of(context).textTheme.titleLarge),
|
||
IconButton(icon: Icon(Icons.chevron_right), onPressed: _changeMonth),
|
||
Spacer(),
|
||
ElevatedButton.icon(
|
||
onPressed: () => setState(() {
|
||
_year = DateTime.now().year;
|
||
_month = DateTime.now().month - 1;
|
||
}),
|
||
icon: Icon(Icons.check),
|
||
label: Text('現在'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(8),
|
||
child: GridView.builder(
|
||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: 7,
|
||
childAspectRatio: 1.5,
|
||
),
|
||
itemCount: 6 * 35 + 2,
|
||
itemBuilder: (context, index) {
|
||
final monthIndex = index ~/ 35;
|
||
final dayOfWeek = index % 7;
|
||
|
||
if (monthIndex >= 6) return const SizedBox();
|
||
|
||
final monthDay = DateTime(_year, monthIndex + 1);
|
||
final dayOfMonthIndex = (monthDay.weekday - 1 + _selectedDate.weekday - 1) % 7;
|
||
final isCurrentMonth = dayOfWeek == dayOfMonthIndex && monthIndex == 0;
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.all(4),
|
||
decoration: BoxDecoration(
|
||
color: isCurrentMonth ? Colors.blue.shade50 : Colors.transparent,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Text(
|
||
dayOfMonthIndex + 1 < 10 ? '0$dayOfMonthIndex' : '$dayOfMonthIndex',
|
||
style: TextStyle(
|
||
fontWeight: isCurrentMonth ? FontWeight.bold : FontStyle.normal,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
|
||
Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: SizedBox(
|
||
height: 48,
|
||
child: ListView.builder(
|
||
scrollDirection: Axis.horizontal,
|
||
itemCount: 6 * 35 + 2,
|
||
itemBuilder: (context, index) {
|
||
final monthIndex = index ~/ 35;
|
||
final dayOfWeek = index % 7;
|
||
|
||
if (monthIndex >= 6) return const SizedBox();
|
||
|
||
final monthDay = DateTime(_year, monthIndex + 1);
|
||
final dayOfMonthIndex = (monthDay.weekday - 1 + _selectedDate.weekday - 1) % 7;
|
||
final isCurrentMonth = dayOfWeek == dayOfMonthIndex && monthIndex == 0;
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.only(right: 8),
|
||
child: ElevatedButton.icon(
|
||
onPressed: () {
|
||
if (mounted) setState(() {
|
||
_year = _selectedDate.year;
|
||
_month = _selectedDate.month - 1;
|
||
});
|
||
Navigator.pop(context);
|
||
},
|
||
icon: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
child: Text(
|
||
'${isCurrentMonth ? "" : "月"}${dayOfMonthIndex + 1}',
|
||
style: TextStyle(fontWeight: isCurrentMonth ? FontWeight.bold : FontWeight.normal),
|
||
),
|
||
),
|
||
label: const SizedBox(width: 32),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
String _formatYear(int year) {
|
||
return year > DateTime.now().year ? '${year - DateTime.now().year}年先' : year;
|
||
}
|
||
|
||
void _changeMonth() {
|
||
setState(() => _month = (_month + 1) % 12);
|
||
}
|
||
} |