oto/packages/flutter/oto_console/lib/src/oto_console_shell.dart
toki 51c5a0f74b feat(client): Job 생성 폼 표시/숨김 제어와 onNewItem 네비게이션 콜백을 추가한다
- OtoConsoleShell에 onNewItem 콜백 파라미터와 _MenuAction 활성 액션 상태를 도입한다
- OtoJobsSurface에 showCreateForm 파라미터를 추가하여 폼 표시/숨김을 외부에서 제어할 수 있게 한다
- OtoClientApp에 _showJobCreateForm 상태 변수를 추가하여 폼 토글을 구현한다
- 관련 테스트를 업데이트한다
2026-07-06 07:51:19 +09:00

978 lines
29 KiB
Dart

import 'package:flutter/material.dart';
import 'oto_agent_panel.dart';
import 'oto_console_contract.dart';
class OtoConsoleShell extends StatefulWidget {
final Widget overview;
final Widget? runners;
final Widget? pipelines;
final Widget? executions;
final Widget? artifacts;
final Widget? agent;
final Widget? settings;
final OtoConsoleSection initialSection;
final OtoConsoleConfig? config;
final OtoConsoleThemeAdapter? themeAdapter;
final OtoConsoleNavigationCallback? onNavigate;
final VoidCallback? onNewItem;
final OtoCapabilityPack? capabilities;
const OtoConsoleShell({
super.key,
required this.overview,
this.runners,
this.pipelines,
this.executions,
this.artifacts,
this.agent,
this.settings,
this.initialSection = OtoConsoleSection.overview,
this.config,
this.themeAdapter,
this.onNavigate,
this.onNewItem,
this.capabilities,
});
@override
State<OtoConsoleShell> createState() => _OtoConsoleShellState();
}
class _OtoConsoleShellState extends State<OtoConsoleShell> {
late OtoConsoleSection _section = widget.initialSection;
_MenuAction? _activeAction;
@override
void didUpdateWidget(covariant OtoConsoleShell oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialSection != widget.initialSection) {
if (_section != widget.initialSection) {
_section = widget.initialSection;
_activeAction = null;
}
}
}
@override
Widget build(BuildContext context) {
final theme = widget.themeAdapter ?? const OtoConsoleThemeAdapter();
final descriptor = _activeAction == _MenuAction.newItem
? const _SectionDescriptor.newItem()
: _SectionDescriptor.forSection(_section);
return Scaffold(
backgroundColor: theme.backgroundColor,
body: SafeArea(
child: _JenkinsConsoleFrame(
descriptor: descriptor,
selected: _section,
activeAction: _activeAction,
onSelect: _select,
theme: theme,
child: _buildContent(theme),
),
),
);
}
void _select(OtoConsoleSection section, [_MenuAction? action]) {
setState(() {
_section = section;
_activeAction = action;
});
widget.onNavigate?.call(section);
if (action == _MenuAction.newItem) {
widget.onNewItem?.call();
}
}
Widget _buildContent(OtoConsoleThemeAdapter theme) {
return switch (_section) {
OtoConsoleSection.overview => widget.overview,
OtoConsoleSection.runners =>
widget.runners ??
OtoConsoleSectionSurface(
icon: Icons.precision_manufacturing_outlined,
title: 'Runners',
emptyTitle: 'No runners connected',
emptyMessage: 'Runner registry is empty.',
theme: theme,
),
OtoConsoleSection.pipelines =>
widget.pipelines ?? _buildDefaultPipelinesSurface(theme),
OtoConsoleSection.executions =>
widget.executions ??
OtoConsoleSectionSurface(
icon: Icons.play_circle_outline,
title: 'Executions',
emptyTitle: 'No executions',
emptyMessage: 'Execution history is empty.',
theme: theme,
),
OtoConsoleSection.artifacts =>
widget.artifacts ??
OtoConsoleSectionSurface(
icon: Icons.inventory_2_outlined,
title: 'Artifacts',
emptyTitle: 'No artifacts',
emptyMessage: 'Artifact events are empty.',
theme: theme,
),
OtoConsoleSection.agent =>
widget.agent ?? OtoAgentPanel(capabilities: widget.capabilities),
OtoConsoleSection.settings =>
widget.settings ??
_ManageOtoSettingsSurface(theme: theme, config: widget.config),
};
}
Widget _buildDefaultPipelinesSurface(OtoConsoleThemeAdapter theme) {
if (_activeAction == _MenuAction.newItem) {
return OtoConsoleSectionSurface(
icon: Icons.add_circle_outline,
title: 'New Item',
emptyTitle: 'Pipeline creation unavailable',
emptyMessage: 'No create surface is mounted.',
theme: theme,
);
}
return OtoConsoleSectionSurface(
icon: Icons.account_tree_outlined,
title: 'Pipelines / Jobs',
emptyTitle: 'No pipeline jobs',
emptyMessage: 'Job queue is empty.',
theme: theme,
);
}
}
enum _MenuAction { newItem }
/// Descriptor for the currently selected section: label, breadcrumb group,
/// and the icon shared by the breadcrumb and the left menu.
class _SectionDescriptor {
final OtoConsoleSection section;
final String label;
final String groupLabel;
final IconData icon;
const _SectionDescriptor({
required this.section,
required this.label,
required this.groupLabel,
required this.icon,
});
const _SectionDescriptor.newItem()
: section = OtoConsoleSection.pipelines,
label = 'New Item',
groupLabel = 'Workflow',
icon = Icons.add_circle_outline;
static _SectionDescriptor forSection(OtoConsoleSection section) {
return switch (section) {
OtoConsoleSection.overview => const _SectionDescriptor(
section: OtoConsoleSection.overview,
label: 'Overview',
groupLabel: 'Dashboard',
icon: Icons.dashboard_outlined,
),
OtoConsoleSection.pipelines => const _SectionDescriptor(
section: OtoConsoleSection.pipelines,
label: 'Pipelines',
groupLabel: 'Workflow',
icon: Icons.account_tree_outlined,
),
OtoConsoleSection.executions => const _SectionDescriptor(
section: OtoConsoleSection.executions,
label: 'Executions',
groupLabel: 'Workflow',
icon: Icons.play_circle_outline,
),
OtoConsoleSection.runners => const _SectionDescriptor(
section: OtoConsoleSection.runners,
label: 'Runners',
groupLabel: 'Operations',
icon: Icons.precision_manufacturing_outlined,
),
OtoConsoleSection.artifacts => const _SectionDescriptor(
section: OtoConsoleSection.artifacts,
label: 'Artifacts',
groupLabel: 'Operations',
icon: Icons.inventory_2_outlined,
),
OtoConsoleSection.agent => const _SectionDescriptor(
section: OtoConsoleSection.agent,
label: 'Agent',
groupLabel: 'Admin',
icon: Icons.smart_toy_outlined,
),
OtoConsoleSection.settings => const _SectionDescriptor(
section: OtoConsoleSection.settings,
label: 'Settings',
groupLabel: 'Admin',
icon: Icons.tune,
),
};
}
}
/// Jenkins Dashboard-aligned shell frame: a top header (breadcrumb, action
/// strip, status/context) above a left menu + content Row. Replaces the
/// former 60px icon-only rail + page-local header layout.
class _JenkinsConsoleFrame extends StatelessWidget {
final _SectionDescriptor descriptor;
final OtoConsoleSection selected;
final _MenuAction? activeAction;
final void Function(OtoConsoleSection section, [_MenuAction? action])
onSelect;
final OtoConsoleThemeAdapter theme;
final Widget child;
const _JenkinsConsoleFrame({
required this.descriptor,
required this.selected,
required this.activeAction,
required this.onSelect,
required this.theme,
required this.child,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_JenkinsHeader(
descriptor: descriptor,
theme: theme,
onNewItem: () =>
onSelect(OtoConsoleSection.pipelines, _MenuAction.newItem),
),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_OtoConsoleLeftMenu(
selected: selected,
activeAction: activeAction,
onSelect: onSelect,
theme: theme,
),
Expanded(child: child),
],
),
),
],
);
}
}
class _JenkinsHeader extends StatelessWidget {
final _SectionDescriptor descriptor;
final OtoConsoleThemeAdapter theme;
final VoidCallback onNewItem;
const _JenkinsHeader({
required this.descriptor,
required this.theme,
required this.onNewItem,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
decoration: BoxDecoration(
color: theme.railBackgroundColor,
border: Border(bottom: BorderSide(color: theme.borderColor)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: _HeaderBreadcrumb(descriptor: descriptor, theme: theme),
),
const SizedBox(width: 16),
_HeaderActionStrip(theme: theme, onNewItem: onNewItem),
],
),
const SizedBox(height: 8),
_HeaderStatusContext(descriptor: descriptor, theme: theme),
],
),
);
}
}
class _HeaderBreadcrumb extends StatelessWidget {
final _SectionDescriptor descriptor;
final OtoConsoleThemeAdapter theme;
const _HeaderBreadcrumb({required this.descriptor, required this.theme});
@override
Widget build(BuildContext context) {
final crumbStyle = TextStyle(color: theme.textSecondaryColor, fontSize: 13);
final activeStyle = TextStyle(
color: theme.textColor,
fontSize: 15,
fontWeight: FontWeight.w800,
);
return Row(
children: [
Text(
'OTO',
style: TextStyle(
color: theme.primaryColor,
fontSize: 15,
fontWeight: FontWeight.w900,
),
),
Text(' / ', style: crumbStyle),
Text(descriptor.groupLabel, style: crumbStyle),
Text(' / ', style: crumbStyle),
Flexible(
child: Text(
descriptor.label,
overflow: TextOverflow.ellipsis,
style: activeStyle,
),
),
],
);
}
}
/// Header-level action slot. The visible `New Item` label lives in the left
/// menu (single source of truth for that primitive), so this strip stays
/// icon-only to avoid a duplicate text match; it remains an extensible slot
/// for 03 to add more contextual page actions.
class _HeaderActionStrip extends StatelessWidget {
final OtoConsoleThemeAdapter theme;
final VoidCallback onNewItem;
const _HeaderActionStrip({required this.theme, required this.onNewItem});
@override
Widget build(BuildContext context) {
return Tooltip(
message: 'New Item',
waitDuration: const Duration(milliseconds: 500),
child: IconButton(
onPressed: onNewItem,
icon: const Icon(Icons.add),
style: IconButton.styleFrom(
foregroundColor: theme.primaryColor,
backgroundColor: theme.primaryColor.withValues(alpha: 0.1),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
),
),
);
}
}
class _HeaderStatusContext extends StatelessWidget {
final _SectionDescriptor descriptor;
final OtoConsoleThemeAdapter theme;
const _HeaderStatusContext({required this.descriptor, required this.theme});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(descriptor.icon, color: theme.primaryColor, size: 16),
const SizedBox(width: 6),
Text(
'${descriptor.label} section',
style: TextStyle(color: theme.textSecondaryColor, fontSize: 12),
),
],
);
}
}
class _RailDivider extends StatelessWidget {
const _RailDivider();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Container(height: 1, color: Colors.white38),
);
}
}
/// A single left-menu destination. `section` is the navigation target;
/// `isPrimaryAction` marks entries (like New Item) that trigger navigation
/// without participating in the persistent "selected" highlight.
class _MenuDestination {
final String label;
final String tooltip;
final IconData icon;
final OtoConsoleSection section;
final _MenuAction? action;
final bool isPrimaryAction;
const _MenuDestination({
required this.label,
required this.tooltip,
required this.icon,
required this.section,
this.action,
this.isPrimaryAction = false,
});
}
/// Jenkins Dashboard-level left menu. Replaces the former icon-only rail
/// with a labeled menu; `New Item`, `Pipelines`, and `Build History` are
/// exposed as readable destinations per the Jenkins IA menu mapping.
/// Structure is intentionally list-driven so 03 can add Nodes/Artifacts/
/// Manage OTO labels without reshaping the frame.
class _OtoConsoleLeftMenu extends StatelessWidget {
final OtoConsoleSection selected;
final _MenuAction? activeAction;
final void Function(OtoConsoleSection section, [_MenuAction? action])
onSelect;
final OtoConsoleThemeAdapter theme;
const _OtoConsoleLeftMenu({
required this.selected,
required this.activeAction,
required this.onSelect,
required this.theme,
});
static const _primaryDestinations = [
_MenuDestination(
label: 'Overview',
tooltip: 'Overview',
icon: Icons.dashboard_outlined,
section: OtoConsoleSection.overview,
),
_MenuDestination(
label: 'New Item',
tooltip: 'New Item',
icon: Icons.add_circle_outline,
section: OtoConsoleSection.pipelines,
action: _MenuAction.newItem,
isPrimaryAction: true,
),
_MenuDestination(
label: 'Pipelines',
tooltip: 'Pipelines',
icon: Icons.account_tree_outlined,
section: OtoConsoleSection.pipelines,
),
_MenuDestination(
label: 'Build History',
tooltip: 'Executions',
icon: Icons.play_circle_outline,
section: OtoConsoleSection.executions,
),
_MenuDestination(
label: 'Runners',
tooltip: 'Runners',
icon: Icons.precision_manufacturing_outlined,
section: OtoConsoleSection.runners,
),
_MenuDestination(
label: 'Artifacts',
tooltip: 'Artifacts',
icon: Icons.inventory_2_outlined,
section: OtoConsoleSection.artifacts,
),
];
static const _adminDestinations = [
_MenuDestination(
label: 'Settings',
tooltip: 'Settings',
icon: Icons.tune,
section: OtoConsoleSection.settings,
),
_MenuDestination(
label: 'Agent',
tooltip: 'Agent',
icon: Icons.smart_toy_outlined,
section: OtoConsoleSection.agent,
),
];
@override
Widget build(BuildContext context) {
return Container(
width: 208,
decoration: BoxDecoration(
color: theme.railBackgroundColor,
border: Border(right: BorderSide(color: theme.borderColor)),
),
child: Column(
children: [
const SizedBox(height: 8),
for (final destination in _primaryDestinations)
_LeftMenuButton(
destination: destination,
selected: _isDestinationSelected(destination),
onPressed: () =>
onSelect(destination.section, destination.action),
theme: theme,
),
const Spacer(),
const _RailDivider(),
for (final destination in _adminDestinations)
_LeftMenuButton(
destination: destination,
selected: _isDestinationSelected(destination),
onPressed: () => onSelect(destination.section),
theme: theme,
),
const SizedBox(height: 8),
],
),
);
}
bool _isDestinationSelected(_MenuDestination destination) {
if (destination.action != null) {
return activeAction == destination.action;
}
return activeAction == null && selected == destination.section;
}
}
class _LeftMenuButton extends StatelessWidget {
final _MenuDestination destination;
final bool selected;
final VoidCallback onPressed;
final OtoConsoleThemeAdapter theme;
const _LeftMenuButton({
required this.destination,
required this.selected,
required this.onPressed,
required this.theme,
});
@override
Widget build(BuildContext context) {
final color = selected || destination.isPrimaryAction
? theme.primaryColor
: theme.textSecondaryColor;
return Tooltip(
message: destination.tooltip,
waitDuration: const Duration(milliseconds: 500),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Material(
color: selected
? theme.primaryColor.withValues(alpha: 0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onPressed,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Icon(destination.icon, color: color, size: 18),
const SizedBox(width: 12),
Expanded(
child: Text(
destination.label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: color,
fontSize: 13,
fontWeight: selected || destination.isPrimaryAction
? FontWeight.w700
: FontWeight.w500,
),
),
),
],
),
),
),
),
),
);
}
}
/// Jenkins Manage Jenkins-aligned settings surface: category cards limited
/// to the OTO contract data actually available today (runtime endpoints,
/// browser access policy) instead of a generic empty-state fallback.
class _ManageOtoSettingsSurface extends StatelessWidget {
final OtoConsoleThemeAdapter theme;
final OtoConsoleConfig? config;
const _ManageOtoSettingsSurface({required this.theme, this.config});
@override
Widget build(BuildContext context) {
return Container(
color: theme.backgroundColor,
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 820),
child: ListView(
shrinkWrap: true,
children: [
Row(
children: [
Icon(Icons.tune, color: theme.primaryColor, size: 24),
const SizedBox(width: 12),
Expanded(
child: Text(
'Manage OTO',
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: theme.textColor,
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
),
],
),
const SizedBox(height: 20),
_ManageCategoryCard(
icon: Icons.dns_outlined,
title: 'System',
description: 'Runtime endpoints this console connects to.',
theme: theme,
details: [
if (config != null)
OtoConsoleSectionDetail(
label: 'HTTP API',
value: config!.serverHttpUrl,
),
if (config != null)
OtoConsoleSectionDetail(
label: 'Wire URL',
value: config!.serverWireUrl,
),
],
),
const SizedBox(height: 16),
_ManageCategoryCard(
icon: Icons.security_outlined,
title: 'Security',
description:
'Browser access policy enforced for this console.',
theme: theme,
detailGroups: const [
OtoConsoleSectionDetailGroup(
title: 'Browser policy',
details: [
OtoConsoleSectionDetail(
label: 'Browser API policy',
value:
'CORS headers or a same-origin proxy are required.',
),
],
),
],
),
],
),
),
),
),
);
}
}
/// A single Manage OTO category card. Supports either a flat `details` list
/// or nested `detailGroups` so categories can subdivide (e.g. Security ->
/// Browser policy) without a new card type.
class _ManageCategoryCard extends StatelessWidget {
final IconData icon;
final String title;
final String description;
final OtoConsoleThemeAdapter theme;
final List<OtoConsoleSectionDetail> details;
final List<OtoConsoleSectionDetailGroup> detailGroups;
const _ManageCategoryCard({
required this.icon,
required this.title,
required this.description,
required this.theme,
this.details = const [],
this.detailGroups = const [],
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.railBackgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: theme.primaryColor, size: 20),
const SizedBox(width: 10),
Text(
title,
style: TextStyle(
color: theme.textColor,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
],
),
const SizedBox(height: 6),
Text(
description,
style: TextStyle(color: theme.textSecondaryColor, fontSize: 12),
),
if (detailGroups.isNotEmpty) ...[
for (final group in detailGroups) ...[
const SizedBox(height: 14),
Text(
group.title,
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
for (final detail in group.details) ...[
_SectionDetailTile(detail: detail, theme: theme),
const SizedBox(height: 8),
],
],
] else if (details.isNotEmpty) ...[
const SizedBox(height: 14),
for (final detail in details) ...[
_SectionDetailTile(detail: detail, theme: theme),
const SizedBox(height: 8),
],
],
],
),
);
}
}
class OtoConsoleSectionDetail {
final String label;
final String value;
const OtoConsoleSectionDetail({required this.label, required this.value});
}
class OtoConsoleSectionDetailGroup {
final String title;
final List<OtoConsoleSectionDetail> details;
const OtoConsoleSectionDetailGroup({
required this.title,
required this.details,
});
}
class OtoConsoleSectionSurface extends StatelessWidget {
final IconData icon;
final String title;
final String emptyTitle;
final String emptyMessage;
final OtoConsoleThemeAdapter theme;
final List<OtoConsoleSectionDetail> details;
final List<OtoConsoleSectionDetailGroup> detailGroups;
const OtoConsoleSectionSurface({
super.key,
required this.icon,
required this.title,
required this.emptyTitle,
required this.emptyMessage,
required this.theme,
this.details = const [],
this.detailGroups = const [],
});
@override
Widget build(BuildContext context) {
return Container(
color: theme.backgroundColor,
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 820),
child: ListView(
shrinkWrap: true,
children: [
Row(
children: [
Icon(icon, color: theme.primaryColor, size: 24),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: theme.textColor,
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
),
],
),
const SizedBox(height: 20),
_EmptyStatePanel(
icon: icon,
title: emptyTitle,
message: emptyMessage,
theme: theme,
),
if (detailGroups.isNotEmpty) ...[
for (final group in detailGroups) ...[
const SizedBox(height: 20),
Text(
group.title,
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
for (final detail in group.details) ...[
_SectionDetailTile(detail: detail, theme: theme),
const SizedBox(height: 10),
],
],
] else if (details.isNotEmpty) ...[
const SizedBox(height: 20),
for (final detail in details) ...[
_SectionDetailTile(detail: detail, theme: theme),
const SizedBox(height: 10),
],
],
],
),
),
),
),
);
}
}
class _EmptyStatePanel extends StatelessWidget {
final IconData icon;
final String title;
final String message;
final OtoConsoleThemeAdapter theme;
const _EmptyStatePanel({
required this.icon,
required this.title,
required this.message,
required this.theme,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.railBackgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.borderColor),
),
child: Row(
children: [
Icon(icon, color: theme.primaryColor, size: 32),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
color: theme.textColor,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
message,
overflow: TextOverflow.ellipsis,
maxLines: 2,
style: TextStyle(color: theme.textSecondaryColor),
),
],
),
),
],
),
);
}
}
class _SectionDetailTile extends StatelessWidget {
final OtoConsoleSectionDetail detail;
final OtoConsoleThemeAdapter theme;
const _SectionDetailTile({required this.detail, required this.theme});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.primaryColor.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.primaryColor.withValues(alpha: 0.18)),
),
child: Row(
children: [
SizedBox(
width: 150,
child: Text(
detail.label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: theme.textSecondaryColor,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
detail.value,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(color: theme.textColor),
),
),
],
),
);
}
}