forked from eva/focus-flow
251 lines
12 KiB
Dart
251 lines
12 KiB
Dart
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
/// Renders the Required Banner widget for the FocusFlow Flutter UI.
|
|
library;
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../models/today_screen_models.dart';
|
|
import '../theme/focus_flow_tokens.dart';
|
|
|
|
/// Banner that highlights the next required task, if one exists.
|
|
class RequiredBanner extends StatelessWidget {
|
|
/// Creates a required-task banner.
|
|
const RequiredBanner({this.model, this.onShowUpcoming, super.key});
|
|
|
|
/// Optional required task model to render.
|
|
final RequiredBannerModel? model;
|
|
|
|
/// Callback invoked when the highlighted required task should be shown.
|
|
final VoidCallback? onShowUpcoming;
|
|
|
|
/// 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 banner = model;
|
|
if (banner == null) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final metrics = _RequiredBannerMetrics.fromWidth(constraints.maxWidth);
|
|
return Container(
|
|
height: _RequiredBannerMetrics.referenceHeight,
|
|
padding: EdgeInsets.symmetric(horizontal: metrics.horizontalPadding),
|
|
decoration: BoxDecoration(
|
|
color: FocusFlowTokens.glassPanel,
|
|
borderRadius: BorderRadius.circular(metrics.bannerRadius),
|
|
border: Border.all(
|
|
color: FocusFlowTokens.navBorder.withValues(alpha: 0.55),
|
|
width: metrics.borderWidth,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: metrics.iconCircleSize,
|
|
height: metrics.iconCircleSize,
|
|
decoration: const BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: FocusFlowTokens.glassPanelStrong,
|
|
),
|
|
child: Icon(
|
|
Icons.auto_awesome,
|
|
color: FocusFlowTokens.accentMagenta,
|
|
size: metrics.iconSize,
|
|
),
|
|
),
|
|
SizedBox(width: metrics.iconGap),
|
|
Expanded(
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: FittedBox(
|
|
alignment: Alignment.centerLeft,
|
|
fit: BoxFit.scaleDown,
|
|
child: RichText(
|
|
text: TextSpan(
|
|
style: TextStyle(
|
|
color: FocusFlowTokens.textPrimary,
|
|
fontSize: metrics.textSize,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
children: [
|
|
const TextSpan(text: 'Next required task: '),
|
|
TextSpan(
|
|
text: banner.title,
|
|
style: const TextStyle(
|
|
color: FocusFlowTokens.accentMagenta,
|
|
),
|
|
),
|
|
TextSpan(
|
|
text: ' at ${banner.timeText}',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w500,
|
|
fontSize: metrics.textSize,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: metrics.buttonGap),
|
|
_UpcomingButton(metrics: metrics, onPressed: onShowUpcoming),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Private implementation type for `_RequiredBannerMetrics` in this library.
|
|
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
|
class _RequiredBannerMetrics {
|
|
/// Creates a `_RequiredBannerMetrics` instance with the values required by its invariants.
|
|
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
|
const _RequiredBannerMetrics(this.scale);
|
|
|
|
/// Shared constant value for `referenceHeight`.
|
|
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
|
|
static const referenceHeight = 72.0;
|
|
|
|
/// Shared constant value for `_referenceWidth`.
|
|
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
|
|
static const _referenceWidth = 760.0;
|
|
|
|
/// Shared constant value for `_minimumScale`.
|
|
/// The constant gives this repeated scheduler or UI value one documented source of truth instead of scattering literals across call sites.
|
|
static const _minimumScale = 0.5;
|
|
|
|
/// Creates a `_RequiredBannerMetrics.fromWidth` instance with the values required by its invariants.
|
|
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
|
factory _RequiredBannerMetrics.fromWidth(double width) {
|
|
final safeWidth = width.isFinite ? width : _referenceWidth;
|
|
final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0);
|
|
return _RequiredBannerMetrics(scale.toDouble());
|
|
}
|
|
|
|
/// Stores the `scale` value for this object.
|
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
|
final double scale;
|
|
|
|
/// Returns the derived `horizontalPadding` value for this object.
|
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
|
double get horizontalPadding => 18 * scale;
|
|
|
|
/// Returns the derived `bannerRadius` value for this object.
|
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
|
double get bannerRadius => FocusFlowTokens.bannerRadius * scale;
|
|
|
|
/// Returns the derived `borderWidth` value for this object.
|
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
|
double get borderWidth => scale < 0.7 ? 0.8 : 1;
|
|
|
|
/// Returns the derived `iconCircleSize` value for this object.
|
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
|
double get iconCircleSize => 48 * scale;
|
|
|
|
/// Returns the derived `iconSize` value for this object.
|
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
|
double get iconSize => 24 * scale;
|
|
|
|
/// Returns the derived `iconGap` value for this object.
|
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
|
double get iconGap => 26 * scale;
|
|
|
|
/// Returns the derived `buttonGap` value for this object.
|
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
|
double get buttonGap => 20 * scale;
|
|
|
|
/// Returns the derived `textSize` value for this object.
|
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
|
double get textSize => (19 * scale).clamp(13.0, 14.0).toDouble();
|
|
|
|
/// Returns the derived `buttonWidth` value for this object.
|
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
|
double get buttonWidth => 176 * scale;
|
|
|
|
/// Returns the derived `buttonHeight` value for this object.
|
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
|
double get buttonHeight => 46 * scale;
|
|
|
|
/// Returns the derived `buttonRadius` value for this object.
|
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
|
double get buttonRadius => FocusFlowTokens.smallButtonRadius * scale;
|
|
|
|
/// Returns the derived `buttonTextSize` value for this object.
|
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
|
double get buttonTextSize => (16 * scale).clamp(11.0, 12.5).toDouble();
|
|
|
|
/// Returns the derived `buttonIconSize` value for this object.
|
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
|
double get buttonIconSize => 20 * scale;
|
|
|
|
/// Returns the derived `buttonTextGap` value for this object.
|
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
|
double get buttonTextGap => 6 * scale;
|
|
|
|
/// Returns the derived `buttonPadding` value for this object.
|
|
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
|
EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale);
|
|
}
|
|
|
|
/// Private implementation type for `_UpcomingButton` in this library.
|
|
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
|
class _UpcomingButton extends StatelessWidget {
|
|
/// Creates a `_UpcomingButton` instance with the values required by its invariants.
|
|
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
|
const _UpcomingButton({required this.metrics, required this.onPressed});
|
|
|
|
/// Stores the `metrics` value for this object.
|
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
|
final _RequiredBannerMetrics metrics;
|
|
|
|
/// Stores the `onPressed` value for this object.
|
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
|
final VoidCallback? onPressed;
|
|
|
|
/// 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 OutlinedButton(
|
|
onPressed: onPressed,
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: FocusFlowTokens.textPrimary,
|
|
side: BorderSide(
|
|
color: FocusFlowTokens.navBorder.withValues(alpha: 0.55),
|
|
width: metrics.borderWidth,
|
|
),
|
|
fixedSize: Size(metrics.buttonWidth, metrics.buttonHeight),
|
|
minimumSize: Size.zero,
|
|
padding: metrics.buttonPadding,
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(metrics.buttonRadius),
|
|
),
|
|
),
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'Show upcoming',
|
|
style: TextStyle(
|
|
fontSize: metrics.buttonTextSize,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
SizedBox(width: metrics.buttonTextGap),
|
|
Icon(Icons.chevron_right, size: metrics.buttonIconSize),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|