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
|
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||||
/// for quick navigation of the BoxScrollView.
|
/// for quick navigation of the BoxScrollView.
|
||||||
class Scrubber extends StatefulWidget {
|
class Scrubber extends ConsumerStatefulWidget {
|
||||||
/// The view that will be scrolled with the scroll thumb
|
/// The view that will be scrolled with the scroll thumb
|
||||||
final CustomScrollView child;
|
final CustomScrollView child;
|
||||||
|
|
||||||
@ -37,7 +37,7 @@ class Scrubber extends StatefulWidget {
|
|||||||
}) : assert(child.scrollDirection == Axis.vertical);
|
}) : assert(child.scrollDirection == Axis.vertical);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State createState() => ScrubberState();
|
ConsumerState createState() => ScrubberState();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<_Segment> _buildSegments({
|
List<_Segment> _buildSegments({
|
||||||
@ -82,7 +82,8 @@ List<_Segment> _buildSegments({
|
|||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
|
class ScrubberState extends ConsumerState<Scrubber>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
double _thumbTopOffset = 0.0;
|
double _thumbTopOffset = 0.0;
|
||||||
bool _isDragging = false;
|
bool _isDragging = false;
|
||||||
List<_Segment> _segments = [];
|
List<_Segment> _segments = [];
|
||||||
@ -175,6 +176,13 @@ class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
|
|||||||
return false;
|
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(() {
|
setState(() {
|
||||||
if (notification is ScrollUpdateNotification) {
|
if (notification is ScrollUpdateNotification) {
|
||||||
_thumbTopOffset = _currentOffset;
|
_thumbTopOffset = _currentOffset;
|
||||||
@ -191,7 +199,7 @@ class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDragStart(WidgetRef ref) {
|
void _onDragStart(DragStartDetails _) {
|
||||||
ref.read(timelineStateProvider.notifier).setScrubbing(true);
|
ref.read(timelineStateProvider.notifier).setScrubbing(true);
|
||||||
setState(() {
|
setState(() {
|
||||||
_isDragging = true;
|
_isDragging = true;
|
||||||
@ -293,10 +301,12 @@ class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
|
|||||||
_scrollController.jumpTo(centeredOffset.clamp(0.0, maxScrollExtent));
|
_scrollController.jumpTo(centeredOffset.clamp(0.0, maxScrollExtent));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDragEnd(WidgetRef ref) {
|
void _onDragEnd(DragEndDetails _) {
|
||||||
ref.read(timelineStateProvider.notifier).setScrubbing(false);
|
ref.read(timelineStateProvider.notifier).setScrubbing(false);
|
||||||
_labelAnimationController.reverse();
|
_labelAnimationController.reverse();
|
||||||
_isDragging = false;
|
setState(() {
|
||||||
|
_isDragging = false;
|
||||||
|
});
|
||||||
|
|
||||||
_resetThumbTimer();
|
_resetThumbTimer();
|
||||||
}
|
}
|
||||||
@ -342,13 +352,10 @@ class ScrubberState extends State<Scrubber> with TickerProviderStateMixin {
|
|||||||
top: _thumbTopOffset + widget.topPadding,
|
top: _thumbTopOffset + widget.topPadding,
|
||||||
end: 0,
|
end: 0,
|
||||||
child: RepaintBoundary(
|
child: RepaintBoundary(
|
||||||
child: Consumer(
|
child: GestureDetector(
|
||||||
builder: (_, ref, child) => GestureDetector(
|
onVerticalDragStart: _onDragStart,
|
||||||
onVerticalDragStart: (_) => _onDragStart(ref),
|
onVerticalDragUpdate: _onDragUpdate,
|
||||||
onVerticalDragUpdate: _onDragUpdate,
|
onVerticalDragEnd: _onDragEnd,
|
||||||
onVerticalDragEnd: (_) => _onDragEnd(ref),
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
child: _Scrubber(
|
child: _Scrubber(
|
||||||
thumbAnimation: _thumbAnimation,
|
thumbAnimation: _thumbAnimation,
|
||||||
labelAnimation: _labelAnimation,
|
labelAnimation: _labelAnimation,
|
||||||
|
@ -40,19 +40,28 @@ class TimelineArgs {
|
|||||||
|
|
||||||
class TimelineState {
|
class TimelineState {
|
||||||
final bool isScrubbing;
|
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
|
@override
|
||||||
bool operator ==(covariant TimelineState other) {
|
bool operator ==(covariant TimelineState other) {
|
||||||
return isScrubbing == other.isScrubbing;
|
return isScrubbing == other.isScrubbing && isScrolling == other.isScrolling;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => isScrubbing.hashCode;
|
int get hashCode => isScrubbing.hashCode ^ isScrolling.hashCode;
|
||||||
|
|
||||||
TimelineState copyWith({bool? isScrubbing}) {
|
TimelineState copyWith({bool? isScrubbing, bool? isScrolling}) {
|
||||||
return TimelineState(isScrubbing: isScrubbing ?? this.isScrubbing);
|
return TimelineState(
|
||||||
|
isScrubbing: isScrubbing ?? this.isScrubbing,
|
||||||
|
isScrolling: isScrolling ?? this.isScrolling,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,8 +72,15 @@ class TimelineStateNotifier extends Notifier<TimelineState> {
|
|||||||
state = state.copyWith(isScrubbing: isScrubbing);
|
state = state.copyWith(isScrubbing: isScrubbing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setScrolling(bool isScrolling) {
|
||||||
|
state = state.copyWith(isScrolling: isScrolling);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@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.
|
// 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/domain/models/setting.model.dart';
|
||||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_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/scrubber.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.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(
|
const Positioned(
|
||||||
top: 60,
|
top: 60,
|
||||||
left: 25,
|
left: 25,
|
||||||
child: _MultiSelectStatusButton(),
|
child: _MultiSelectStatusButton(),
|
||||||
),
|
),
|
||||||
|
const HomeBottomAppBar(),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user