From b001ba44f5131513506c194103869f7c2f6034c5 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 25 Jun 2025 11:08:02 -0500 Subject: [PATCH] feat: generic control bottom app bar (#19524) * feat: sliver appbar * feat: snapping segment * Date label font size * lint * fix: scrollController reinitialize multiple times * feat: tab navigation * chore: refactor to private widget * feat: new control bottom app bar * bad merge * feat: sliver control bottom app bar --- .../base_action_button.widget.dart | 47 +++++++ .../share_action_button.widget.dart | 19 +++ .../base_bottom_sheet.widget.dart | 126 ++++++++++++++++++ .../home_bottom_app_bar.widget.dart | 17 +++ .../widgets/timeline/scrubber.widget.dart | 33 +++-- .../widgets/timeline/timeline.state.dart | 28 +++- .../widgets/timeline/timeline.widget.dart | 5 +- 7 files changed, 255 insertions(+), 20 deletions(-) create mode 100644 mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart create mode 100644 mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart create mode 100644 mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart create mode 100644 mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart new file mode 100644 index 0000000000..8ea43b8781 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +class BaseActionButton extends StatelessWidget { + const BaseActionButton({ + super.key, + required this.label, + required this.iconData, + this.onPressed, + this.onLongPressed, + }); + + final String label; + final IconData iconData; + final void Function()? onPressed; + final void Function()? onLongPressed; + + @override + Widget build(BuildContext context) { + final minWidth = + context.isMobile ? MediaQuery.sizeOf(context).width / 4.5 : 75.0; + + return MaterialButton( + padding: const EdgeInsets.all(10), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + onPressed: onPressed, + onLongPress: onLongPressed, + minWidth: minWidth, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(iconData, size: 24), + const SizedBox(height: 8), + Text( + label, + style: const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400), + maxLines: 3, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart new file mode 100644 index 0000000000..4e6b6e2af4 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; + +class ShareActionButton extends ConsumerWidget { + const ShareActionButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: + Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, + label: context.tr('share'), + ); + } +} diff --git a/mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart new file mode 100644 index 0000000000..d041bca514 --- /dev/null +++ b/mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; + +class BaseBottomSheet extends ConsumerStatefulWidget { + final List actions; + final DraggableScrollableController? controller; + final List? slivers; + final double initialChildSize; + final double minChildSize; + final double maxChildSize; + final bool expand; + final bool shouldCloseOnMinExtent; + final bool resizeOnScroll; + + const BaseBottomSheet({ + super.key, + required this.actions, + this.slivers, + this.controller, + this.initialChildSize = 0.35, + this.minChildSize = 0.15, + this.maxChildSize = 0.65, + this.expand = true, + this.shouldCloseOnMinExtent = true, + this.resizeOnScroll = true, + }); + + @override + ConsumerState createState() => + _BaseDraggableScrollableSheetState(); +} + +class _BaseDraggableScrollableSheetState + extends ConsumerState { + late DraggableScrollableController _controller; + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? DraggableScrollableController(); + } + + @override + Widget build(BuildContext context) { + ref.listen(timelineStateProvider, (previous, next) { + if (!widget.resizeOnScroll) { + return; + } + + if (previous?.isInteracting != true && next.isInteracting) { + _controller.animateTo( + widget.minChildSize, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ); + } + }); + + return DraggableScrollableSheet( + controller: _controller, + initialChildSize: widget.initialChildSize, + minChildSize: widget.minChildSize, + maxChildSize: widget.maxChildSize, + snap: false, + expand: widget.expand, + shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent, + builder: (BuildContext context, ScrollController scrollController) { + return Card( + color: context.colorScheme.surfaceContainerHigh, + surfaceTintColor: context.colorScheme.surfaceContainerHigh, + borderOnForeground: false, + clipBehavior: Clip.antiAlias, + elevation: 6.0, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(6), + topRight: Radius.circular(6), + ), + ), + margin: const EdgeInsets.symmetric(horizontal: 0), + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter( + child: Column( + children: [ + const SizedBox(height: 16), + const _DragHandle(), + const SizedBox(height: 16), + SizedBox( + height: 120, + child: ListView( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + children: widget.actions, + ), + ), + ], + ), + ), + ...(widget.slivers ?? []), + ], + ), + ); + }, + ); + } +} + +class _DragHandle extends StatelessWidget { + const _DragHandle(); + + @override + Widget build(BuildContext context) { + return Container( + height: 6, + width: 32, + decoration: BoxDecoration( + color: context.themeData.dividerColor, + borderRadius: const BorderRadius.all(Radius.circular(20)), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart b/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart new file mode 100644 index 0000000000..c29149df85 --- /dev/null +++ b/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart'; + +class HomeBottomAppBar extends ConsumerWidget { + const HomeBottomAppBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return const BaseBottomSheet( + actions: [ + ShareActionButton(), + ], + ); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart index 63f8f9926a..e660f77767 100644 --- a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart @@ -13,7 +13,7 @@ import 'package:intl/intl.dart' hide TextDirection; /// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged /// for quick navigation of the BoxScrollView. -class Scrubber extends StatefulWidget { +class Scrubber extends ConsumerStatefulWidget { /// The view that will be scrolled with the scroll thumb final CustomScrollView child; @@ -37,7 +37,7 @@ class Scrubber extends StatefulWidget { }) : assert(child.scrollDirection == Axis.vertical); @override - State createState() => ScrubberState(); + ConsumerState createState() => ScrubberState(); } List<_Segment> _buildSegments({ @@ -82,7 +82,8 @@ List<_Segment> _buildSegments({ return segments; } -class ScrubberState extends State with TickerProviderStateMixin { +class ScrubberState extends ConsumerState + with TickerProviderStateMixin { double _thumbTopOffset = 0.0; bool _isDragging = false; List<_Segment> _segments = []; @@ -175,6 +176,13 @@ class ScrubberState extends State with TickerProviderStateMixin { return false; } + if (notification is ScrollStartNotification || + notification is ScrollUpdateNotification) { + ref.read(timelineStateProvider.notifier).setScrolling(true); + } else if (notification is ScrollEndNotification) { + ref.read(timelineStateProvider.notifier).setScrolling(false); + } + setState(() { if (notification is ScrollUpdateNotification) { _thumbTopOffset = _currentOffset; @@ -191,7 +199,7 @@ class ScrubberState extends State with TickerProviderStateMixin { return false; } - void _onDragStart(WidgetRef ref) { + void _onDragStart(DragStartDetails _) { ref.read(timelineStateProvider.notifier).setScrubbing(true); setState(() { _isDragging = true; @@ -293,10 +301,12 @@ class ScrubberState extends State with TickerProviderStateMixin { _scrollController.jumpTo(centeredOffset.clamp(0.0, maxScrollExtent)); } - void _onDragEnd(WidgetRef ref) { + void _onDragEnd(DragEndDetails _) { ref.read(timelineStateProvider.notifier).setScrubbing(false); _labelAnimationController.reverse(); - _isDragging = false; + setState(() { + _isDragging = false; + }); _resetThumbTimer(); } @@ -342,13 +352,10 @@ class ScrubberState extends State with TickerProviderStateMixin { top: _thumbTopOffset + widget.topPadding, end: 0, child: RepaintBoundary( - child: Consumer( - builder: (_, ref, child) => GestureDetector( - onVerticalDragStart: (_) => _onDragStart(ref), - onVerticalDragUpdate: _onDragUpdate, - onVerticalDragEnd: (_) => _onDragEnd(ref), - child: child, - ), + child: GestureDetector( + onVerticalDragStart: _onDragStart, + onVerticalDragUpdate: _onDragUpdate, + onVerticalDragEnd: _onDragEnd, child: _Scrubber( thumbAnimation: _thumbAnimation, labelAnimation: _labelAnimation, diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart index 6e38bf2ac1..629fac7831 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -40,19 +40,28 @@ class TimelineArgs { class TimelineState { final bool isScrubbing; + final bool isScrolling; - const TimelineState({this.isScrubbing = false}); + const TimelineState({ + this.isScrubbing = false, + this.isScrolling = false, + }); + + bool get isInteracting => isScrubbing || isScrolling; @override bool operator ==(covariant TimelineState other) { - return isScrubbing == other.isScrubbing; + return isScrubbing == other.isScrubbing && isScrolling == other.isScrolling; } @override - int get hashCode => isScrubbing.hashCode; + int get hashCode => isScrubbing.hashCode ^ isScrolling.hashCode; - TimelineState copyWith({bool? isScrubbing}) { - return TimelineState(isScrubbing: isScrubbing ?? this.isScrubbing); + TimelineState copyWith({bool? isScrubbing, bool? isScrolling}) { + return TimelineState( + isScrubbing: isScrubbing ?? this.isScrubbing, + isScrolling: isScrolling ?? this.isScrolling, + ); } } @@ -63,8 +72,15 @@ class TimelineStateNotifier extends Notifier { state = state.copyWith(isScrubbing: isScrubbing); } + void setScrolling(bool isScrolling) { + state = state.copyWith(isScrolling: isScrolling); + } + @override - TimelineState build() => const TimelineState(isScrubbing: false); + TimelineState build() => const TimelineState( + isScrubbing: false, + isScrolling: false, + ); } // This provider watches the buckets from the timeline service & args and serves the segments. diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 109c4e0de0..688675c686 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; @@ -120,12 +121,14 @@ class _SliverTimelineState extends State<_SliverTimeline> { ], ), ), - if (isMultiSelectEnabled) + if (isMultiSelectEnabled) ...[ const Positioned( top: 60, left: 25, child: _MultiSelectStatusButton(), ), + const HomeBottomAppBar(), + ], ], ), );