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
This commit is contained in:
Alex 2025-06-25 11:08:02 -05:00 committed by GitHub
parent afb444c92c
commit b001ba44f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 255 additions and 20 deletions

View File

@ -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,
),
],
),
);
}
}

View File

@ -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'),
);
}
}

View File

@ -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<Widget> actions;
final DraggableScrollableController? controller;
final List<Widget>? 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<BaseBottomSheet> createState() =>
_BaseDraggableScrollableSheetState();
}
class _BaseDraggableScrollableSheetState
extends ConsumerState<BaseBottomSheet> {
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)),
),
);
}
}

View File

@ -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(),
],
);
}
}

View File

@ -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<Scrubber> with TickerProviderStateMixin {
class ScrubberState extends ConsumerState<Scrubber>
with TickerProviderStateMixin {
double _thumbTopOffset = 0.0;
bool _isDragging = false;
List<_Segment> _segments = [];
@ -175,6 +176,13 @@ class ScrubberState extends State<Scrubber> 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<Scrubber> 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<Scrubber> 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<Scrubber> 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,

View File

@ -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<TimelineState> {
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.

View File

@ -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(),
],
],
),
);