forked from eva/focus-flow
fix: jump required banner to linked task
This commit is contained in:
parent
adf5422917
commit
1e0e76d72f
5 changed files with 106 additions and 16 deletions
|
|
@ -113,7 +113,7 @@ class _PlanIssue extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class _TodayScreenScaffold extends StatelessWidget {
|
||||
class _TodayScreenScaffold extends StatefulWidget {
|
||||
const _TodayScreenScaffold({
|
||||
required this.data,
|
||||
required this.selectedCard,
|
||||
|
|
@ -126,6 +126,25 @@ class _TodayScreenScaffold extends StatelessWidget {
|
|||
final ValueChanged<TimelineCardModel> onSelectCard;
|
||||
final VoidCallback onClearSelection;
|
||||
|
||||
@override
|
||||
State<_TodayScreenScaffold> createState() => _TodayScreenScaffoldState();
|
||||
}
|
||||
|
||||
class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
||||
String? _timelineScrollTargetCardId;
|
||||
var _timelineScrollRequest = 0;
|
||||
|
||||
void _showUpcomingRequiredTask() {
|
||||
final targetCardId = widget.data.requiredBanner?.timelineCardId;
|
||||
if (targetCardId == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_timelineScrollTargetCardId = targetCardId;
|
||||
_timelineScrollRequest += 1;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
|
|
@ -140,33 +159,38 @@ class _TodayScreenScaffold extends StatelessWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TopBar(dateLabel: data.dateLabel),
|
||||
TopBar(dateLabel: widget.data.dateLabel),
|
||||
const SizedBox(height: 24),
|
||||
RequiredBanner(model: data.requiredBanner),
|
||||
RequiredBanner(
|
||||
model: widget.data.requiredBanner,
|
||||
onShowUpcoming: _showUpcomingRequiredTask,
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Expanded(
|
||||
child: TimelineView(
|
||||
cards: data.cards,
|
||||
range: data.timelineRange,
|
||||
onCardSelected: onSelectCard,
|
||||
cards: widget.data.cards,
|
||||
range: widget.data.timelineRange,
|
||||
scrollTargetCardId: _timelineScrollTargetCardId,
|
||||
scrollRequest: _timelineScrollRequest,
|
||||
onCardSelected: widget.onSelectCard,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (selectedCard != null) ...[
|
||||
if (widget.selectedCard != null) ...[
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('modal-click-away'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onClearSelection,
|
||||
onTap: widget.onClearSelection,
|
||||
child: const ColoredBox(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: TaskSelectionModal(
|
||||
card: selectedCard!,
|
||||
onClose: onClearSelection,
|
||||
card: widget.selectedCard!,
|
||||
onClose: widget.onClearSelection,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -6,7 +6,14 @@ import 'package:scheduler_core/scheduler_core.dart';
|
|||
/// Presentation model for the next-required-task banner.
|
||||
class RequiredBannerModel {
|
||||
/// Creates a required banner model.
|
||||
const RequiredBannerModel({required this.title, required this.timeText});
|
||||
const RequiredBannerModel({
|
||||
required this.timelineCardId,
|
||||
required this.title,
|
||||
required this.timeText,
|
||||
});
|
||||
|
||||
/// Timeline card id linked to the highlighted required task.
|
||||
final String timelineCardId;
|
||||
|
||||
/// Title of the next required task.
|
||||
final String title;
|
||||
|
|
@ -79,6 +86,7 @@ class TodayScreenData {
|
|||
requiredBanner: nextRequired == null
|
||||
? null
|
||||
: RequiredBannerModel(
|
||||
timelineCardId: nextRequired.id,
|
||||
title: nextRequired.item.displayTitle,
|
||||
timeText: _formatTime(nextRequired.start),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -9,11 +9,14 @@ 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, super.key});
|
||||
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;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final banner = model;
|
||||
|
|
@ -85,7 +88,7 @@ class RequiredBanner extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
SizedBox(width: metrics.buttonGap),
|
||||
_UpcomingButton(metrics: metrics),
|
||||
_UpcomingButton(metrics: metrics, onPressed: onShowUpcoming),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -127,14 +130,15 @@ class _RequiredBannerMetrics {
|
|||
}
|
||||
|
||||
class _UpcomingButton extends StatelessWidget {
|
||||
const _UpcomingButton({required this.metrics});
|
||||
const _UpcomingButton({required this.metrics, required this.onPressed});
|
||||
|
||||
final _RequiredBannerMetrics metrics;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OutlinedButton(
|
||||
onPressed: () {},
|
||||
onPressed: onPressed,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: FocusFlowTokens.textPrimary,
|
||||
side: BorderSide(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ class TimelineView extends StatefulWidget {
|
|||
required this.range,
|
||||
required this.onCardSelected,
|
||||
this.now,
|
||||
this.scrollTargetCardId,
|
||||
this.scrollRequest = 0,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -32,6 +34,12 @@ class TimelineView extends StatefulWidget {
|
|||
/// Clock used to center the initial scroll position.
|
||||
final DateTime Function()? now;
|
||||
|
||||
/// Card id requested by another surface, such as the required banner.
|
||||
final String? scrollTargetCardId;
|
||||
|
||||
/// Monotonic request number that allows repeated jumps to the same card.
|
||||
final int scrollRequest;
|
||||
|
||||
@override
|
||||
State<TimelineView> createState() => _TimelineViewState();
|
||||
}
|
||||
|
|
@ -39,6 +47,7 @@ class TimelineView extends StatefulWidget {
|
|||
class _TimelineViewState extends State<TimelineView> {
|
||||
final _scrollController = ScrollController();
|
||||
bool _didSetInitialScroll = false;
|
||||
var _handledScrollRequest = 0;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TimelineView oldWidget) {
|
||||
|
|
@ -67,6 +76,7 @@ class _TimelineViewState extends State<TimelineView> {
|
|||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_scheduleInitialScroll(geometry, constraints.maxHeight);
|
||||
_scheduleTargetScroll(geometry);
|
||||
return SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
child: SizedBox(
|
||||
|
|
@ -131,4 +141,33 @@ class _TimelineViewState extends State<TimelineView> {
|
|||
_didSetInitialScroll = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _scheduleTargetScroll(TimelineGeometry geometry) {
|
||||
final targetCardId = widget.scrollTargetCardId;
|
||||
if (targetCardId == null || widget.scrollRequest == _handledScrollRequest) {
|
||||
return;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_scrollController.hasClients) {
|
||||
return;
|
||||
}
|
||||
TimelineCardModel? targetCard;
|
||||
for (final card in widget.cards) {
|
||||
if (card.id == targetCardId) {
|
||||
targetCard = card;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetCard == null) {
|
||||
_handledScrollRequest = widget.scrollRequest;
|
||||
return;
|
||||
}
|
||||
final targetOffset =
|
||||
geometry.yForMinutesSinceMidnight(targetCard.startMinutes) - 24;
|
||||
final maxOffset = _scrollController.position.maxScrollExtent;
|
||||
final offset = targetOffset.clamp(0, maxOffset).toDouble();
|
||||
_scrollController.jumpTo(offset);
|
||||
_handledScrollRequest = widget.scrollRequest;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,17 @@ void main() {
|
|||
expect(TodayScreenData.defaultRange.endMinutes, 24 * 60);
|
||||
});
|
||||
|
||||
testWidgets('required banner jumps timeline to linked task', (tester) async {
|
||||
await _pumpPlanApp(tester);
|
||||
|
||||
final scrollable = tester.state<ScrollableState>(find.byType(Scrollable));
|
||||
await tester.tap(find.text('Show upcoming'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(scrollable.position.pixels, closeTo(2622, 1));
|
||||
expect(find.byKey(const ValueKey('pay-bill')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'timeline defaults near the current time while keeping full day',
|
||||
(tester) async {
|
||||
|
|
@ -171,7 +182,11 @@ void main() {
|
|||
tester,
|
||||
size: const Size(440, 72),
|
||||
child: const RequiredBanner(
|
||||
model: RequiredBannerModel(title: 'Pay bill', timeText: '6:00 PM'),
|
||||
model: RequiredBannerModel(
|
||||
timelineCardId: 'pay-bill',
|
||||
title: 'Pay bill',
|
||||
timeText: '6:00 PM',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue