- OtoConsoleShell에 onNewItem 콜백 파라미터와 _MenuAction 활성 액션 상태를 도입한다 - OtoJobsSurface에 showCreateForm 파라미터를 추가하여 폼 표시/숨김을 외부에서 제어할 수 있게 한다 - OtoClientApp에 _showJobCreateForm 상태 변수를 추가하여 폼 토글을 구현한다 - 관련 테스트를 업데이트한다
978 lines
29 KiB
Dart
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|