Files
superport_v2/lib/widgets/components/form_field.dart
JiWoong Sul d76f765814 feat(approvals): Approval Flow v2 프런트엔드 전면 개편
- 환경/라우터 모듈에 approval_flow_v2 토글을 추가하고 FeatureFlags 초기화를 연결 (.env*, lib/core/**)
- ApiClient 빌더·ApiRoutes 확장과 ApprovalRepositoryRemote 리팩터링으로 include·액션 시그니처를 정합화
- ApprovalFlow·ApprovalDraft 엔티티/레포/유즈케이스를 도입해 서버 초안과 단계 액션(승인·회수·재상신)을 지원
- Approval 컨트롤러·히스토리·템플릿 페이지와 공유 위젯을 재작성해 감사 로그·회수 UX·템플릿 CRUD를 반영
- Inbound/Outbound/Rental 컨트롤러·페이지에 결재 섹션을 삽입하고 대시보드 pending 카드 요약을 갱신
- SuperportDialog·FormField 등 공통 위젯을 보강하고 승인 위젯 가이드를 추가해 UI 가이드를 정리
- 결재/재고 테스트 픽스처와 단위·위젯·통합 테스트를 확장하고 flutter_test_config로 스테이징 호스트를 허용
- Approval Flow 레포트/플랜 문서를 업데이트하고 ApprovalFlow_System_Integration_and_ChangePlan.md를 추가
- 실행: flutter analyze, flutter test
2025-10-31 01:05:39 +09:00

215 lines
5.6 KiB
Dart

import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
const double _kFieldSpacing = 8;
const double _kFieldCaptionSpacing = 6;
/// 폼 필드 라벨과 본문을 일관되게 배치하기 위한 위젯.
class SuperportFormField extends StatelessWidget {
const SuperportFormField({
super.key,
required this.label,
required this.child,
this.required = false,
this.caption,
this.errorText,
this.trailing,
this.spacing = _kFieldSpacing,
});
/// 폼 필드 라벨 텍스트.
final String label;
/// 입력 영역으로 렌더링할 위젯.
final Widget child;
/// 필수 여부. true면 라벨 옆에 `*` 표시를 추가한다.
final bool required;
/// 보조 설명 문구. 에러가 없을 때만 출력된다.
final String? caption;
/// 에러 메시지. 존재하면 캡션 대신 우선적으로 노출된다.
final String? errorText;
/// 라벨 우측에 배치할 추가 위젯(예: 도움말 버튼).
final Widget? trailing;
/// 라벨과 본문 사이 간격.
final double spacing;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final captionStyle = theme.textTheme.muted.copyWith(fontSize: 12);
final errorStyle = theme.textTheme.small.copyWith(
fontSize: 12,
color: theme.colorScheme.destructive,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _FieldLabel(label: label, required: required),
),
if (trailing != null) trailing!,
],
),
SizedBox(height: spacing),
child,
if (errorText != null && errorText!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: _kFieldCaptionSpacing),
child: Text(errorText!, style: errorStyle),
)
else if (caption != null && caption!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: _kFieldCaptionSpacing),
child: Text(caption!, style: captionStyle),
),
],
);
}
}
/// `ShadInput`을 Superport 스타일에 맞게 설정한 텍스트 필드.
class SuperportTextInput extends StatelessWidget {
const SuperportTextInput({
super.key,
this.controller,
this.placeholder,
this.onChanged,
this.onSubmitted,
this.keyboardType,
this.enabled = true,
this.readOnly = false,
this.maxLines = 1,
this.leading,
this.trailing,
});
final TextEditingController? controller;
/// 입력 없을 때 보여줄 플레이스홀더 위젯.
final Widget? placeholder;
/// 입력 변경 콜백.
final ValueChanged<String>? onChanged;
/// 제출(Enter) 시 호출되는 콜백.
final ValueChanged<String>? onSubmitted;
/// 키보드 타입. 숫자/이메일 등으로 지정 가능.
final TextInputType? keyboardType;
/// 입력 활성 여부.
final bool enabled;
/// 읽기 전용 여부. true면 수정 불가.
final bool readOnly;
/// 최대 줄 수. 1보다 크면 멀티라인 입력을 지원한다.
final int maxLines;
/// 앞에 붙일 위젯 (아이콘 등).
final Widget? leading;
/// 뒤에 붙일 위젯 (버튼 등).
final Widget? trailing;
@override
Widget build(BuildContext context) {
return ShadInput(
controller: controller,
placeholder: placeholder,
enabled: enabled,
readOnly: readOnly,
keyboardType: keyboardType,
maxLines: maxLines,
leading: leading,
trailing: trailing,
onChanged: onChanged,
onSubmitted: onSubmitted,
);
}
}
/// `ShadSwitch`를 라벨과 함께 사용하기 위한 헬퍼.
class SuperportSwitchField extends StatelessWidget {
const SuperportSwitchField({
super.key,
required this.value,
required this.onChanged,
this.label,
this.caption,
});
/// 스위치 현재 상태.
final bool value;
/// 상태 변경 시 호출되는 콜백.
final ValueChanged<bool> onChanged;
/// 스위치 상단에 표시할 제목.
final String? label;
/// 보조 설명 문구.
final String? caption;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (label != null) Text(label!, style: theme.textTheme.small),
const SizedBox(height: 8),
ShadSwitch(value: value, onChanged: onChanged),
if (caption != null && caption!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: _kFieldCaptionSpacing),
child: Text(caption!, style: theme.textTheme.muted),
),
],
);
}
}
class _FieldLabel extends StatelessWidget {
const _FieldLabel({required this.label, required this.required});
final String label;
final bool required;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final textStyle = theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: textStyle),
if (required)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
'*',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.destructive,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
}