Files
immich/mobile/lib/presentation/widgets/action_buttons/upload_action_button.widget.dart
T
Peter Ombodi 9d4a6614b1 feat(mobile): Android. Immich as a gallery / image viewer app (#26109)
* 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>
2026-06-03 12:05:52 -05:00

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