feat:更新表单组件

This commit is contained in:
2025-12-09 10:19:50 +08:00
parent adae2bc558
commit 190963f079
12 changed files with 270 additions and 527 deletions

View File

@@ -1,45 +0,0 @@
import 'package:fitnote/widget/record/common.dart';
import 'package:flutter/material.dart';
import 'package:getwidget/getwidget.dart';
class DropdownSelect extends StatelessWidget {
final String value;
final ValueChanged<String?> onChanged;
final List<String> items;
const DropdownSelect({
Key? key,
required this.value,
required this.onChanged,
required this.items,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final double width = 150;
final double height = 36;
return Container(
width: width,
decoration: buildBoxDecoration(context),
child: DropdownButtonHideUnderline(
child: GFDropdown(
itemHeight: height,
padding: const EdgeInsets.all(0),
borderRadius: BorderRadius.circular(8),
value: value,
onChanged: onChanged,
items:
items
.map(
(value) => DropdownMenuItem(
value: value,
child: Text(value, style: const TextStyle(fontSize: 16)),
),
)
.toList(),
),
),
);
}
}

157
lib/widget/form.dart Normal file
View File

@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:intl/intl.dart';
TextStyle _titleStyle() {
return TextStyle(fontSize: 16, fontWeight: FontWeight.w600);
}
TextStyle _contentStyle() {
return TextStyle(fontSize: 16, fontWeight: FontWeight.w500);
}
OutlineInputBorder _border(ColorScheme colors) {
return OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colors.onSurface.withAlpha(100), width: 1.5),
);
}
OutlineInputBorder _enabledBorder(ColorScheme colors) {
return OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colors.onSurface.withAlpha(100), width: 1.5),
);
}
OutlineInputBorder _focusedBorder(ColorScheme colors) {
return OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colors.primary, width: 2),
);
}
EdgeInsetsGeometry _containerPadding() {
return EdgeInsets.symmetric(horizontal: 16, vertical: 10);
}
Widget buildFormTime({
required BuildContext context,
required String name,
required String title,
required DateTime? initialValue,
required ValueChanged<DateTime?> onChanged,
double width = 100,
String hintText = '请选择时间',
}) {
final colors = Theme.of(context).colorScheme;
final alphaColor = colors.onSurface.withAlpha(100);
return Row(
children: [
Container(width: width, child: Text(title, style: _titleStyle())),
const SizedBox(width: 8),
Expanded(
child: FormBuilderDateTimePicker(
name: name,
inputType: InputType.time,
initialTime: TimeOfDay.now(),
initialValue: initialValue,
format: DateFormat.Hm(),
onChanged: (value) => onChanged(value),
style: _contentStyle(),
decoration: InputDecoration(
hintText: hintText,
hintStyle: TextStyle(color: alphaColor),
prefixIcon: Icon(Icons.schedule_rounded, size: 24),
border: _border(colors),
enabledBorder: _enabledBorder(colors),
focusedBorder: _focusedBorder(colors),
filled: true,
fillColor: colors.surfaceContainer,
contentPadding: _containerPadding(),
),
),
),
],
);
}
Widget buildFormSlider({
required BuildContext context,
required String name,
required String title,
required String label,
required double min,
required double max,
required double initialValue,
required int divisions,
required ValueChanged<double?> onChanged,
double width = 100,
}) {
final colors = Theme.of(context).colorScheme;
final alphaColor = colors.onSurface.withAlpha(100);
return Row(
children: [
Container(width: width, child: Text(title, style: _titleStyle())),
const SizedBox(width: 8),
Expanded(
child: FormBuilderSlider(
name: name,
min: min,
max: max,
initialValue: initialValue,
divisions: divisions,
onChanged: (value) => onChanged(value),
label: label,
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
),
],
);
}
Widget buildFormRadioGroup({
required BuildContext context,
required String name,
required String title,
required String initialValue,
required List<String> items,
required ValueChanged<String?> onChanged,
double width = 100,
}) {
final colors = Theme.of(context).colorScheme;
final alphaColor = colors.onSurface.withAlpha(100);
return Row(
children: [
Container(width: width, child: Text(title, style: _titleStyle())),
const SizedBox(width: 8),
Expanded(
child: FormBuilderRadioGroup<String>(
name: name,
options:
items
.map(
(item) =>
FormBuilderFieldOption(value: item, child: Text(item)),
)
.toList(),
initialValue: initialValue,
activeColor: colors.primary,
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
onChanged: (value) => onChanged(value),
),
),
],
);
}

View File

@@ -1,129 +0,0 @@
import 'package:fitnote/widget/record/common.dart';
import 'package:flutter/material.dart';
class NumberInput extends StatefulWidget {
final num? value;
final ValueChanged<num?>? onChanged;
final double min;
final double max;
final double step;
final int precision;
final String unit;
const NumberInput({
Key? key,
this.value,
this.onChanged,
this.min = double.negativeInfinity,
this.max = double.infinity,
this.step = 1,
this.precision = 2,
this.unit = ''
}) : super(key: key);
@override
State<NumberInput> createState() => _NumberInputState();
}
class _NumberInputState extends State<NumberInput> {
late TextEditingController _controller;
late num _currentValue;
final Color disabledColor = Colors.grey.shade400;
final double width = 150;
final double height = 36;
@override
void initState() {
super.initState();
_currentValue = widget.value ?? 0.0;
_controller = TextEditingController(text: _formatValue(_currentValue));
}
@override
void didUpdateWidget(NumberInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != oldWidget.value && widget.value != _currentValue) {
_currentValue = widget.value ?? 0.0;
_controller.text = _formatValue(_currentValue);
}
}
String _formatValue(num value) {
if (widget.precision == 0) return value.toInt().toString();
return value.toStringAsFixed(widget.precision);
}
void _handleValueChange(num newValue) {
num clampedValue = newValue.clamp(widget.min, widget.max);
if (widget.precision > 0) {
clampedValue = double.parse(
clampedValue.toStringAsFixed(widget.precision),
);
}
setState(() {
_currentValue = clampedValue;
_controller.text = _formatValue(clampedValue);
});
widget.onChanged?.call(clampedValue);
}
Widget _buildControlButton({
required IconData icon,
required VoidCallback? onPressed,
required bool disabled,
}) {
final colors = Theme.of(context).colorScheme;
return Container(
width: height,
height: height,
child: Material(
color: disabled ? disabledColor : colors.primary.withAlpha(100),
child: InkWell(
onTap: disabled ? null : onPressed,
borderRadius: BorderRadius.circular(4),
child: Icon(icon, size: 18, color: colors.onSurface),
),
),
);
}
@override
Widget build(BuildContext context) {
return Container(
width: width,
decoration: buildBoxDecoration(context),
child: IntrinsicWidth(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildControlButton(
icon: Icons.remove,
onPressed: () => _handleValueChange(_currentValue - widget.step),
disabled: _currentValue <= widget.min,
),
Expanded(
child: Text(
'${_currentValue} ${widget.unit}',
textAlign: TextAlign.center,
),
),
_buildControlButton(
icon: Icons.add,
onPressed: () => _handleValueChange(_currentValue + widget.step),
disabled: _currentValue >= widget.max,
),
],
),
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}

View File

@@ -108,7 +108,8 @@ class _RecordDailyState extends State<RecordDaily> {
_buildRecordTitle(type),
const SizedBox(height: 4),
_buildRecordContent(provider.currentRecord, type),
if (checkHasRecordValue(provider.currentRecord, type))
const SizedBox(height: 4),
if (checkNeedRecordTip(provider.currentRecord, type))
_buildRecordTip(
provider.currentRecord,
provider.selectedDate,

View File

@@ -3,9 +3,7 @@ import 'package:fitnote/models/health.dart';
import 'package:fitnote/provider/health_provider.dart';
import 'package:fitnote/service/health_service.dart';
import 'package:fitnote/utils/record_utils.dart';
import 'package:fitnote/widget/dropdown_select.dart';
import 'package:fitnote/widget/number_input.dart';
import 'package:fitnote/widget/time_picker.dart';
import 'package:fitnote/widget/form.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -79,22 +77,18 @@ class _RecordFormState extends State<RecordForm> {
children: [
_buildFormTitle('记录体重'),
SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildFiledTitle('体重:'),
SizedBox(width: 10),
NumberInput(
value: provider.formItem.weight,
min: 0,
max: 100,
step: 0.1,
unit: 'Kg',
onChanged: (value) => provider.updateWeight(value!),
),
],
buildFormSlider(
context: context,
name: 'slider',
title: '体重Kg',
label: provider.formItem.weight.toString(),
min: 70,
max: 75,
initialValue: 72,
divisions: 50,
onChanged: (value) => provider.updateWeight(value!),
),
SizedBox(height: 20),
SizedBox(height: 10),
],
);
case RecordType.sport:
@@ -103,81 +97,66 @@ class _RecordFormState extends State<RecordForm> {
children: [
_buildFormTitle('记录运动'),
SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildFiledTitle('运动类型:'),
SizedBox(width: 10),
DropdownSelect(
value: provider.formItem.sportProject,
items: AppConfig.sportProjectList,
onChanged: (value) => provider.updateSportProject(value!),
),
],
buildFormRadioGroup(
context: context,
title: '运动类型',
name: 'sport',
initialValue: provider.formItem.sportProject,
items: AppConfig.sportProjectList,
onChanged: (value) => provider.updateSportProject(value!),
),
SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildFiledTitle('运动时长:'),
SizedBox(width: 10),
NumberInput(
value: provider.formItem.sportDuration,
min: 5,
max: 240,
step: 5,
unit: '分钟',
onChanged: (value) => provider.updateSportDuration(value!),
),
],
SizedBox(height: 10),
buildFormSlider(
context: context,
name: 'slider',
title: '时长(分钟)',
label: provider.formItem.sportDuration.toString(),
min: 0,
max: 120,
initialValue: 30,
divisions: 12,
onChanged: (value) => provider.updateSportDuration(value!),
),
SizedBox(height: 24),
SizedBox(height: 10),
],
);
case RecordType.sleep:
final startTime = provider.formItem.sleepStartTime;
final endTime = provider.formItem.sleepEndTime;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildFormTitle('记录睡眠'),
SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildFiledTitle('入睡时间'),
SizedBox(width: 10),
TimePicker(
initialTime: parseTime(provider.formItem.sleepStartTime),
label: provider.formItem.sleepStartTime,
onTimeSelected: (time) {
provider.updateSleepStartTime(formatTime(time));
final startTime = provider.formItem.sleepStartTime;
final endTime = provider.formItem.sleepEndTime;
calSleepDuration(startTime, endTime);
},
),
],
buildFormTime(
context: context,
name: 'startTime',
title: '入睡时间',
initialValue: timeOfDayToDateTime(parseTime(startTime)),
onChanged: (time) {
provider.updateSleepStartTime(formatTime(time!));
final startTime = provider.formItem.sleepStartTime;
final endTime = provider.formItem.sleepEndTime;
calSleepDuration(startTime, endTime);
},
),
SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildFiledTitle('起床时间'),
SizedBox(width: 10),
TimePicker(
initialTime: parseTime(provider.formItem.sleepEndTime),
label: provider.formItem.sleepEndTime,
onTimeSelected: (time) {
provider.updateSleepEndTime(formatTime(time));
final startTime = provider.formItem.sleepStartTime;
final endTime = provider.formItem.sleepEndTime;
calSleepDuration(startTime, endTime);
},
),
],
SizedBox(height: 10),
buildFormTime(
context: context,
name: 'endTime',
title: '起床时间',
initialValue: timeOfDayToDateTime(parseTime(endTime)),
onChanged: (time) {
provider.updateSleepEndTime(formatTime(time!));
final startTime = provider.formItem.sleepStartTime;
final endTime = provider.formItem.sleepEndTime;
calSleepDuration(startTime, endTime);
},
),
SizedBox(height: 20),
SizedBox(height: 10),
_buildSleepDuration(provider.formItem),
SizedBox(height: 24),
SizedBox(height: 10),
],
);
}

View File

@@ -1,188 +0,0 @@
import 'package:flutter/material.dart';
class SelectInputField extends StatefulWidget {
final List<String> options;
final String? value;
final ValueChanged<String?>? onChanged;
final String hintText;
final String labelText;
final bool enabled;
final EdgeInsetsGeometry? padding;
final InputBorder? border;
const SelectInputField({
Key? key,
required this.options,
this.value,
this.onChanged,
this.hintText = '请选择或输入',
this.labelText = '',
this.enabled = true,
this.padding,
this.border,
}) : super(key: key);
@override
State<SelectInputField> createState() => _SelectInputFieldState();
}
class _SelectInputFieldState extends State<SelectInputField> {
final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode();
bool _showDropdown = false;
String? _selectedValue;
@override
void initState() {
super.initState();
_selectedValue = widget.value;
_controller.text = widget.value ?? '';
}
@override
void didUpdateWidget(SelectInputField oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != oldWidget.value) {
_selectedValue = widget.value;
_controller.text = widget.value ?? '';
}
}
void _onTextChanged(String text) {
setState(() {
_selectedValue = text.isEmpty ? null : text;
_showDropdown = text.isNotEmpty;
});
widget.onChanged?.call(text.isEmpty ? null : text);
}
void _onOptionSelected(String option) {
setState(() {
_selectedValue = option;
_controller.text = option;
_showDropdown = false;
});
widget.onChanged?.call(option);
_focusNode.unfocus();
}
void _onFieldTap() {
setState(() {
_showDropdown = !_showDropdown;
});
}
void _onFocusChange(bool hasFocus) {
setState(() {
_showDropdown = hasFocus;
});
}
List<String> get _filteredOptions {
if (_controller.text.isEmpty) {
return widget.options;
}
return widget.options
.where((option) =>
option.toLowerCase().contains(_controller.text.toLowerCase()))
.toList();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.labelText.isNotEmpty) ...[
Text(
widget.labelText,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey[700],
),
),
const SizedBox(height: 8),
],
Stack(
children: [
TextField(
controller: _controller,
focusNode: _focusNode,
onChanged: _onTextChanged,
onTap: _onFieldTap,
enabled: widget.enabled,
decoration: InputDecoration(
hintText: widget.hintText,
border: widget.border ?? const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 14,
),
suffixIcon: IconButton(
icon: Icon(
_showDropdown ? Icons.arrow_drop_up : Icons.arrow_drop_down,
color: Colors.grey[600],
),
onPressed: () {
setState(() {
_showDropdown = !_showDropdown;
});
if (_showDropdown) {
_focusNode.requestFocus();
} else {
_focusNode.unfocus();
}
},
),
),
),
if (_showDropdown && _filteredOptions.isNotEmpty)
Positioned(
top: 60,
left: 0,
right: 0,
child: _buildDropdownList(),
),
],
),
],
);
}
Widget _buildDropdownList() {
return Material(
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[300]!),
),
constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: _filteredOptions.length,
itemBuilder: (context, index) {
final option = _filteredOptions[index];
return ListTile(
title: Text(option),
onTap: () => _onOptionSelected(option),
dense: true,
visualDensity: VisualDensity.compact,
);
},
),
),
);
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
}

View File

@@ -1,69 +0,0 @@
import 'package:fitnote/widget/record/common.dart';
import 'package:flutter/material.dart';
class TimePicker extends StatelessWidget {
final TimeOfDay initialTime;
final ValueChanged<TimeOfDay> onTimeSelected;
final String? label;
const TimePicker({
super.key,
required this.initialTime,
required this.onTimeSelected,
this.label,
});
@override
Widget build(BuildContext context) {
final double width = 150;
final double height = 36;
return Container(
width: width,
height: height,
decoration: buildBoxDecoration(context),
child: InkWell(
onTap: () => _showTimePicker(context),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.access_time, size: 20, color: Colors.grey[600]),
const SizedBox(width: 12),
Text(
label ?? _formatTime(initialTime),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey[800],
),
),
const SizedBox(width: 8),
Icon(Icons.arrow_drop_down, size: 20, color: Colors.grey[500]),
],
),
),
),
);
}
Future<void> _showTimePicker(BuildContext context) async {
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: initialTime,
initialEntryMode: TimePickerEntryMode.dialOnly,
);
if (picked != null) {
onTimeSelected(picked);
}
}
String _formatTime(TimeOfDay time) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
}