mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 13:55:19 -04:00
9d4a6614b1
* feat(mobile): handle Android ACTION_VIEW intent - add ViewIntent Pigeon API and generated bindings - implement Android ViewIntentPlugin + iOS no-op host - route ExternalMediaViewer by ViewIntentAttachment - buffer pending view intents and flush on user ready/resume * feat(mobile): fallback to computed checksum for timeline match - hash local asset on-demand when checksum missing - search main timeline by localId or checksum before standalone viewer - persist computed hash into local_asset_entity * fix(mobile): proper handling is user authenticated * feat(mobile): open ACTION_VIEW fallback in AssetViewer drop ExternalMediaViewer route * feat(mobile): add logger * test(mobile): add unit tests for view intent pending/flush flow * fix(mobile): fix format * fix(mobile): remove redundant iOS code update code related to LocalAsset model and asset viewer * refactor(mobile): simplify view intent flow and support file-backed ACTION_VIEW assets remove redundant view intent model/repository layer handle transient ACTION_VIEW files in viewer/upload flow clean up managed temp files for fallback assets * refactor(mobile): extract MediaStore utils and resolve view intents via merged assets * refactor(mobile): move deferred view intents into providers, split view-intent providers, and clean up ACTION_VIEW handling * refactor(mobile): resolve merge conflicts use NativeSyncApi for hash files instead method from removed BackgroundServicePlugin.kt * style(mobile): format files * style(mobile): format files #2 * refactor(mobile): lazily materialize view-intent files and clean up temp-file handling * fix(mobile): flush pending view intents after login navigation * refactor(mobile): split view intent handler by platform and trigger it from app events * refactor(mobile): move view intent handling behind platform-specific factories * refactor(mobile): simplify code * fix(mobile): hand off deep-link viewer to main timeline after upload Add MainTimelineHandoffCoordinator to switch the asset viewer to the main timeline once a view-intent asset is uploaded and becomes available, and guard viewer reload/navigation transitions to avoid race conditions and crashes. * refactor(mobile): use remote asset ids for view intent handoff and simplify resolver * refactor(mobile): resolve merge conflicts * style(mobile): reformat code * style(mobile): reformat code #2 * fix(mobile): stabilize Android view intent asset resolution and fallback viewer * refactor(mobile): share AssetViewer pre-navigation state preparation * fix(mobile): wait for main timeline before deferred view intent handoff * refactor(mobile): decouple view intent asset resolver from providers * fix(mobile): avoid double pop when canceling upload dialog * fix(mobile): resolve view intent MIME type with fallbacks * docs(mobile): clarify view intent fallback asset TODO * fix(mobile): resolve merge conflicts * cleanup * lint --------- Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com> Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
153 lines
5.2 KiB
Dart
153 lines
5.2 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:fluttertoast/fluttertoast.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:immich_mobile/constants/enums.dart';
|
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/backup/asset_upload_progress.provider.dart';
|
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
|
import 'package:immich_mobile/providers/view_intent/view_intent_file_path.provider.dart';
|
|
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
|
import 'package:immich_mobile/services/view_intent.service.dart';
|
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
import 'package:immich_ui/immich_ui.dart';
|
|
|
|
class UploadActionButton extends ConsumerWidget {
|
|
final ActionSource source;
|
|
final bool iconOnly;
|
|
final bool menuItem;
|
|
|
|
const UploadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
|
|
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
if (!context.mounted) {
|
|
return;
|
|
}
|
|
|
|
final isTimeline = source == ActionSource.timeline;
|
|
final viewerIntentFilePath = source == ActionSource.viewer ? ref.read(viewIntentFilePathProvider) : null;
|
|
List<LocalAsset>? assets;
|
|
var isUploadDialogOpen = false;
|
|
var wasUploadCancelled = false;
|
|
Future<void>? uploadDialogFuture;
|
|
|
|
if (source == ActionSource.timeline) {
|
|
assets = ref.read(multiSelectProvider).selectedAssets.whereType<LocalAsset>().toList();
|
|
if (assets.isEmpty) {
|
|
return;
|
|
}
|
|
ref.read(multiSelectProvider.notifier).reset();
|
|
} else {
|
|
isUploadDialogOpen = true;
|
|
uploadDialogFuture =
|
|
showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (dialogContext) => _UploadProgressDialog(
|
|
onCancel: () {
|
|
wasUploadCancelled = true;
|
|
},
|
|
),
|
|
).whenComplete(() {
|
|
isUploadDialogOpen = false;
|
|
});
|
|
unawaited(uploadDialogFuture);
|
|
}
|
|
|
|
var success = false;
|
|
if (!isTimeline && viewerIntentFilePath != null) {
|
|
final viewIntentService = ref.read(viewIntentServiceProvider);
|
|
viewIntentService.markUploadActive(viewerIntentFilePath);
|
|
var hasError = false;
|
|
try {
|
|
await ref
|
|
.read(foregroundUploadServiceProvider)
|
|
.uploadShareIntent(
|
|
[File(viewerIntentFilePath)],
|
|
onError: (_, _) {
|
|
hasError = true;
|
|
},
|
|
);
|
|
} finally {
|
|
await viewIntentService.markUploadInactive(viewerIntentFilePath);
|
|
}
|
|
success = !hasError;
|
|
} else {
|
|
final result = await ref.read(actionProvider.notifier).upload(source, assets: assets);
|
|
success = result.success;
|
|
}
|
|
|
|
if (!isTimeline && context.mounted && isUploadDialogOpen) {
|
|
Navigator.of(context, rootNavigator: true).pop();
|
|
}
|
|
|
|
if (context.mounted && !success && !wasUploadCancelled) {
|
|
ImmichToast.show(
|
|
context: context,
|
|
msg: 'scaffold_body_error_occurred'.t(context: context),
|
|
gravity: ToastGravity.BOTTOM,
|
|
toastType: ToastType.error,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return BaseActionButton(
|
|
iconData: Icons.backup_outlined,
|
|
label: "upload".t(context: context),
|
|
iconOnly: iconOnly,
|
|
menuItem: menuItem,
|
|
onPressed: () => _onTap(context, ref),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _UploadProgressDialog extends ConsumerWidget {
|
|
final VoidCallback onCancel;
|
|
|
|
const _UploadProgressDialog({required this.onCancel});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final progressMap = ref.watch(assetUploadProgressProvider);
|
|
|
|
// Calculate overall progress from all assets
|
|
final values = progressMap.values.where((v) => v >= 0).toList();
|
|
final progress = values.isEmpty ? 0.0 : values.reduce((a, b) => a + b) / values.length;
|
|
final hasError = progressMap.values.any((v) => v < 0);
|
|
final percentage = (progress * 100).toInt();
|
|
|
|
return AlertDialog(
|
|
title: Text('uploading'.t(context: context)),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (hasError)
|
|
const Icon(Icons.error_outline, color: Colors.red, size: 48)
|
|
else
|
|
CircularProgressIndicator(value: progress > 0 ? progress : null),
|
|
const SizedBox(height: 16),
|
|
Text(hasError ? 'Error' : '$percentage%'),
|
|
],
|
|
),
|
|
actions: [
|
|
ImmichTextButton(
|
|
onPressed: () {
|
|
ref.read(manualUploadCancelTokenProvider)?.complete();
|
|
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
|
|
onCancel();
|
|
Navigator.of(context, rootNavigator: true).pop();
|
|
},
|
|
labelText: 'cancel'.t(context: context),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|