mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
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:
parent
afb444c92c
commit
b001ba44f5
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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.
|
||||
|
@ -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(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
Loading…
x
Reference in New Issue
Block a user