Merge branch 'main' of github.com:immich-app/immich into feat/mobile-platform-clients
This commit is contained in:
@@ -1,54 +1,45 @@
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class DownloadActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool menuItem;
|
||||
const DownloadActionButton({super.key, required this.source, this.menuItem = false});
|
||||
|
||||
const DownloadActionButton({super.key, required this.source});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).downloadAll(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
try {
|
||||
await ref.read(actionProvider.notifier).downloadAll(source);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
} else if (result.count > 0) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'download_action_prompt'.t(context: context, args: {'count': result.count.toString()}),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.success,
|
||||
);
|
||||
Future.delayed(const Duration(seconds: 1), () async {
|
||||
await backgroundSyncManager.syncLocal();
|
||||
await backgroundSyncManager.hashAssets();
|
||||
});
|
||||
} finally {
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final backgroundManager = ref.watch(backgroundSyncProvider);
|
||||
|
||||
return BaseActionButton(
|
||||
iconData: Icons.download,
|
||||
maxWidth: 95,
|
||||
label: "download".t(context: context),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref, backgroundManager),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
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/providers/asset_viewer/download.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class DownloadStatusFloatingButton extends ConsumerWidget {
|
||||
const DownloadStatusFloatingButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final shouldShow = ref.watch(downloadStateProvider.select((state) => state.showProgress));
|
||||
final itemCount = ref.watch(downloadStateProvider.select((state) => state.taskProgress.length));
|
||||
final isDownloading = ref
|
||||
.watch(downloadStateProvider.select((state) => state.taskProgress))
|
||||
.values
|
||||
.where((element) => element.progress != 1)
|
||||
.isNotEmpty;
|
||||
|
||||
return shouldShow
|
||||
? Badge.count(
|
||||
count: itemCount,
|
||||
textColor: context.colorScheme.onPrimary,
|
||||
backgroundColor: context.colorScheme.primary,
|
||||
child: FloatingActionButton(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
side: BorderSide(color: context.colorScheme.outlineVariant, width: 1),
|
||||
),
|
||||
backgroundColor: context.isDarkTheme
|
||||
? context.colorScheme.surfaceContainer
|
||||
: context.colorScheme.surfaceBright,
|
||||
elevation: 2,
|
||||
onPressed: () {
|
||||
context.pushRoute(const DownloadInfoRoute());
|
||||
},
|
||||
child: Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
children: [
|
||||
isDownloading
|
||||
? Icon(Icons.downloading_rounded, color: context.colorScheme.primary, size: 28)
|
||||
: Icon(
|
||||
Icons.download_done,
|
||||
color: context.isDarkTheme ? Colors.green[200] : Colors.green[400],
|
||||
size: 28,
|
||||
),
|
||||
if (isDownloading)
|
||||
const SizedBox(
|
||||
height: 31,
|
||||
width: 31,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
backgroundColor: Colors.transparent,
|
||||
value: null, // Indeterminate progress
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
@@ -649,20 +650,25 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
appBar: const ViewerTopAppBar(),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
body: PhotoViewGallery.builder(
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: _placeholderBuilder,
|
||||
pageController: pageController,
|
||||
scrollPhysics: CurrentPlatform.isIOS
|
||||
? const FastScrollPhysics() // Use bouncing physics for iOS
|
||||
: const FastClampingScrollPhysics(), // Use heavy physics for Android
|
||||
itemCount: totalAssets,
|
||||
onPageChanged: _onPageChanged,
|
||||
onPageBuild: _onPageBuild,
|
||||
scaleStateChangedCallback: _onScaleStateChanged,
|
||||
builder: _assetBuilder,
|
||||
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
||||
enablePanAlways: true,
|
||||
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGallery.builder(
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: _placeholderBuilder,
|
||||
pageController: pageController,
|
||||
scrollPhysics: CurrentPlatform.isIOS
|
||||
? const FastScrollPhysics() // Use bouncing physics for iOS
|
||||
: const FastClampingScrollPhysics(), // Use heavy physics for Android
|
||||
itemCount: totalAssets,
|
||||
onPageChanged: _onPageChanged,
|
||||
onPageBuild: _onPageBuild,
|
||||
scaleStateChangedCallback: _onScaleStateChanged,
|
||||
builder: _assetBuilder,
|
||||
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
||||
enablePanAlways: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: showingBottomSheet
|
||||
? const SizedBox.shrink()
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||
@@ -56,6 +57,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||
|
||||
final actions = <Widget>[
|
||||
if (asset.hasRemote) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
|
||||
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
||||
if (album != null && album.isActivityEnabled && album.isShared)
|
||||
IconButton(
|
||||
|
||||
@@ -31,6 +31,11 @@ class Scrubber extends ConsumerStatefulWidget {
|
||||
|
||||
final double? monthSegmentSnappingOffset;
|
||||
|
||||
final bool snapToMonth;
|
||||
|
||||
/// Whether an app bar is present, affects coordinate calculations
|
||||
final bool hasAppBar;
|
||||
|
||||
Scrubber({
|
||||
super.key,
|
||||
Key? scrollThumbKey,
|
||||
@@ -39,6 +44,8 @@ class Scrubber extends ConsumerStatefulWidget {
|
||||
this.topPadding = 0,
|
||||
this.bottomPadding = 0,
|
||||
this.monthSegmentSnappingOffset,
|
||||
this.snapToMonth = true,
|
||||
this.hasAppBar = true,
|
||||
required this.child,
|
||||
}) : assert(child.scrollDirection == Axis.vertical);
|
||||
|
||||
@@ -232,7 +239,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
}
|
||||
}
|
||||
|
||||
if (_monthCount < kMinMonthsToEnableScrubberSnap) {
|
||||
if (_monthCount < kMinMonthsToEnableScrubberSnap || !widget.snapToMonth) {
|
||||
// If there are less than kMinMonthsToEnableScrubberSnap months, we don't need to snap to segments
|
||||
setState(() {
|
||||
_thumbTopOffset = dragPosition;
|
||||
@@ -259,14 +266,28 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
||||
/// - If user drags to global Y position that's 100 pixels from the top
|
||||
/// - The relative position would be 100 - 50 = 50 (50 pixels into the scrubber area)
|
||||
double _calculateDragPosition(DragUpdateDetails details) {
|
||||
if (widget.hasAppBar) {
|
||||
final dragAreaTop = widget.topPadding;
|
||||
final dragAreaBottom = widget.timelineHeight - widget.bottomPadding;
|
||||
final dragAreaHeight = dragAreaBottom - dragAreaTop;
|
||||
|
||||
final relativePosition = details.globalPosition.dy - dragAreaTop;
|
||||
|
||||
// Make sure the position stays within the scrubber's bounds
|
||||
return relativePosition.clamp(0.0, dragAreaHeight);
|
||||
}
|
||||
|
||||
// Get the local position relative to the gesture detector
|
||||
final RenderBox? renderBox = context.findRenderObject() as RenderBox?;
|
||||
if (renderBox != null) {
|
||||
final localPosition = renderBox.globalToLocal(details.globalPosition);
|
||||
return localPosition.dy.clamp(0.0, _scrubberHeight);
|
||||
}
|
||||
|
||||
// Fallback to current logic if render box is not available
|
||||
final dragAreaTop = widget.topPadding;
|
||||
final dragAreaBottom = widget.timelineHeight - widget.bottomPadding;
|
||||
final dragAreaHeight = dragAreaBottom - dragAreaTop;
|
||||
|
||||
final relativePosition = details.globalPosition.dy - dragAreaTop;
|
||||
|
||||
// Make sure the position stays within the scrubber's bounds
|
||||
return relativePosition.clamp(0.0, dragAreaHeight);
|
||||
return relativePosition.clamp(0.0, _scrubberHeight);
|
||||
}
|
||||
|
||||
/// Find the segment closest to the given position
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
@@ -38,6 +39,7 @@ class Timeline extends StatelessWidget {
|
||||
this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.18),
|
||||
this.groupBy,
|
||||
this.withScrubber = true,
|
||||
this.snapToMonth = true,
|
||||
});
|
||||
|
||||
final Widget? topSliverWidget;
|
||||
@@ -48,11 +50,13 @@ class Timeline extends StatelessWidget {
|
||||
final bool withStack;
|
||||
final GroupAssetsBy? groupBy;
|
||||
final bool withScrubber;
|
||||
final bool snapToMonth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||
body: LayoutBuilder(
|
||||
builder: (_, constraints) => ProviderScope(
|
||||
overrides: [
|
||||
@@ -73,6 +77,7 @@ class Timeline extends StatelessWidget {
|
||||
appBar: appBar,
|
||||
bottomSheet: bottomSheet,
|
||||
withScrubber: withScrubber,
|
||||
snapToMonth: snapToMonth,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -87,6 +92,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
this.appBar,
|
||||
this.bottomSheet,
|
||||
this.withScrubber = true,
|
||||
this.snapToMonth = true,
|
||||
});
|
||||
|
||||
final Widget? topSliverWidget;
|
||||
@@ -94,6 +100,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
||||
final Widget? appBar;
|
||||
final Widget? bottomSheet;
|
||||
final bool withScrubber;
|
||||
final bool snapToMonth;
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _SliverTimelineState();
|
||||
@@ -309,11 +316,13 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
final Widget timeline;
|
||||
if (widget.withScrubber) {
|
||||
timeline = Scrubber(
|
||||
snapToMonth: widget.snapToMonth,
|
||||
layoutSegments: segments,
|
||||
timelineHeight: maxHeight,
|
||||
topPadding: topPadding,
|
||||
bottomPadding: bottomPadding,
|
||||
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
||||
hasAppBar: widget.appBar != null,
|
||||
child: grid,
|
||||
);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user