focus-flow/apps/focus_flow_flutter/lib/widgets/read_state_view.dart

184 lines
5.4 KiB
Dart

// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders the Read State View widget for the FocusFlow Flutter UI.
library;
import 'package:flutter/material.dart';
import '../controllers/scheduler_read_controller.dart';
/// Renders loading, empty, failure, and data states from a read controller.
class ReadStateView<T> extends StatelessWidget {
/// Creates a read-state view.
const ReadStateView({
required this.controller,
required this.title,
required this.emptyTitle,
required this.emptyMessage,
required this.dataBuilder,
super.key,
});
/// Controller that owns the current read state.
final UiReadController<T> controller;
/// Name of the surface being loaded, used in failure copy.
final String title;
/// Title shown when the read succeeds with empty content.
final String emptyTitle;
/// Message shown when the read succeeds with empty content.
final String emptyMessage;
/// Builds the populated content for a read value.
final Widget Function(BuildContext context, T value) dataBuilder;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
return switch (controller.state) {
SchedulerReadLoading<T>() => const Center(
child: CircularProgressIndicator(),
),
SchedulerReadData<T>(value: final value) => dataBuilder(
context,
value,
),
SchedulerReadEmpty<T>(value: final value) => EmptyStatePanel(
title: emptyTitle,
message: emptyMessage,
child: dataBuilder(context, value),
),
SchedulerReadFailure<T>(failure: final failure, error: final error) =>
FailureStatePanel(
title: 'Could not load $title',
code: failure?.detailCode ?? failure?.code.name ?? 'unexpected',
technicalDetail: error?.toString(),
onRetry: controller.retry,
),
};
},
);
}
}
/// Standard empty-state panel with optional surrounding content.
class EmptyStatePanel extends StatelessWidget {
/// Creates an empty-state panel.
const EmptyStatePanel({
required this.title,
required this.message,
this.child,
super.key,
});
/// Primary empty-state title.
final String title;
/// Supporting empty-state message.
final String message;
/// Optional content shown alongside the empty-state panel.
final Widget? child;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
final panel = Padding(
padding: const EdgeInsets.all(16),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(message),
],
),
),
),
);
final content = child;
if (content == null) {
return Center(child: panel);
}
return Column(
children: [
Expanded(child: content),
panel,
],
);
}
}
/// Standard failure panel with a retry action.
class FailureStatePanel extends StatelessWidget {
/// Creates a failure-state panel.
const FailureStatePanel({
required this.title,
required this.code,
required this.onRetry,
this.technicalDetail,
super.key,
});
/// Primary failure title.
final String title;
/// Displayable failure code.
final String code;
/// Optional technical detail for unexpected errors.
final String? technicalDetail;
/// Callback invoked when the user retries the read.
final Future<void> Function() onRetry;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text('Status: $code'),
if (technicalDetail != null) ...[
const SizedBox(height: 8),
Text(technicalDetail!),
],
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () async {
await onRetry();
},
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
),
),
);
}
}