feat(mobile): share to mechanism (#15229)

* setup ios

* chore: succesfully sent media to the app

* share from Android

* wip: navigate to share screen

* wip: UI for displaying upload candidate

* wip: logic

* wip: upload logic

* wip: up up up we got it up

* wip

* wip

* wip

* upload state

* feat: i18n

* fix: release build ios'

* feat: clear file cache

* pr feedback

* using const for checking download status

---------

Co-authored-by: Alex <alex@pop-os.localdomain>
This commit is contained in:
Alex
2025-01-16 21:20:44 -06:00
committed by GitHub
parent 3a2bf91889
commit fd99bd05cf
33 changed files with 1368 additions and 55 deletions
+2
View File
@@ -1 +1,3 @@
const int noDbId = -9223372036854775808; // from Isar
const double downloadCompleted = -1;
const double downloadFailed = -2;
@@ -0,0 +1,7 @@
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
abstract interface class IShareHandlerRepository {
void Function(List<ShareIntentAttachment>)? onSharedMedia;
Future<void> init();
}
@@ -0,0 +1,11 @@
import 'package:background_downloader/background_downloader.dart';
abstract interface class IUploadRepository {
void Function(TaskStatusUpdate)? onUploadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
Future<bool> upload(UploadTask task);
Future<bool> cancel(String id);
Future<void> deleteAllTrackingRecords();
Future<void> deleteRecordsWithIds(List<String> id);
}
+6 -1
View File
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:timezone/data/latest.dart';
import 'package:isar/isar.dart';
@@ -107,10 +108,12 @@ Future<void> initApp() async {
progressBar: true,
);
FileDownloader().trackTasksInGroup(
await FileDownloader().trackTasksInGroup(
downloadGroupLivePhoto,
markDownloadedComplete: false,
);
await FileDownloader().trackTasks();
}
Future<Isar> loadDb() async {
@@ -208,6 +211,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
// needs to be delayed so that EasyLocalization is working
ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
});
ref.read(shareIntentUploadProvider.notifier).init();
}
@override
@@ -0,0 +1,114 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
import 'dart:io';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:path/path.dart';
enum ShareIntentAttachmentType {
image,
video,
}
enum UploadStatus {
enqueued,
running,
complete,
notFound,
failed,
canceled,
waitingtoRetry,
paused,
}
class ShareIntentAttachment {
final String path;
// enum
final ShareIntentAttachmentType type;
// enum
final UploadStatus status;
final double uploadProgress;
final int fileLength;
ShareIntentAttachment({
required this.path,
required this.type,
required this.status,
this.uploadProgress = 0,
this.fileLength = 0,
});
int get id => hash(path);
File get file => File(path);
String get fileName => basename(file.path);
bool get isImage => type == ShareIntentAttachmentType.image;
bool get isVideo => type == ShareIntentAttachmentType.video;
String? _fileSize;
String get fileSize => _fileSize ??= formatHumanReadableBytes(fileLength, 2);
ShareIntentAttachment copyWith({
String? path,
ShareIntentAttachmentType? type,
UploadStatus? status,
double? uploadProgress,
}) {
return ShareIntentAttachment(
path: path ?? this.path,
type: type ?? this.type,
status: status ?? this.status,
uploadProgress: uploadProgress ?? this.uploadProgress,
);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{
'path': path,
'type': type.index,
'status': status.index,
'uploadProgress': uploadProgress,
};
}
factory ShareIntentAttachment.fromMap(Map<String, dynamic> map) {
return ShareIntentAttachment(
path: map['path'] as String,
type: ShareIntentAttachmentType.values[map['type'] as int],
status: UploadStatus.values[map['status'] as int],
uploadProgress: map['uploadProgress'] as double,
);
}
String toJson() => json.encode(toMap());
factory ShareIntentAttachment.fromJson(String source) =>
ShareIntentAttachment.fromMap(
json.decode(source) as Map<String, dynamic>,
);
@override
String toString() {
return 'ShareIntentAttachment(path: $path, type: $type, status: $status, uploadProgress: $uploadProgress)';
}
@override
bool operator ==(covariant ShareIntentAttachment other) {
if (identical(this, other)) return true;
return other.path == path && other.type == type;
}
@override
int get hashCode {
return path.hashCode ^ type.hashCode;
}
}
@@ -32,7 +32,7 @@ class AlbumSharedUserIcons extends HookConsumerWidget {
}
return GestureDetector(
onTap: () => context.pushRoute(AlbumOptionsRoute()),
onTap: () => context.pushRoute(const AlbumOptionsRoute()),
child: SizedBox(
height: 50,
child: ListView.builder(
+35 -19
View File
@@ -13,6 +13,9 @@ class LargeLeadingTile extends StatelessWidget {
horizontal: 16.0,
),
this.borderRadius = 20.0,
this.trailing,
this.selected = false,
this.disabled = false,
});
final Widget leading;
@@ -21,30 +24,43 @@ class LargeLeadingTile extends StatelessWidget {
final Widget? subtitle;
final EdgeInsetsGeometry leadingPadding;
final double borderRadius;
final Widget? trailing;
final bool selected;
final bool disabled;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(borderRadius),
onTap: onTap,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: leadingPadding,
child: leading,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: context.width * 0.6,
child: title,
onTap: disabled ? null : onTap,
child: Container(
decoration: BoxDecoration(
color: selected
? Theme.of(context).primaryColor.withAlpha(30)
: Colors.transparent,
borderRadius: BorderRadius.circular(borderRadius),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: leadingPadding,
child: leading,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: context.width * 0.6,
child: title,
),
subtitle ?? const SizedBox.shrink(),
],
),
subtitle ?? const SizedBox.shrink(),
],
),
],
),
if (trailing != null) trailing!,
],
),
),
);
}
+2
View File
@@ -34,10 +34,12 @@ class PhotosPage extends HookConsumerWidget {
Future(() => ref.read(assetProvider.notifier).getAllAsset());
Future(() => ref.read(albumProvider.notifier).refreshRemoteAlbums());
ref.read(serverInfoProvider.notifier).getServerInfo();
return;
},
[],
);
Widget buildLoadingIndicator() {
Timer(const Duration(seconds: 2), () => tipOneOpacity.value = 1);
@@ -0,0 +1,263 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/asset_viewer/share_intent_upload.provider.dart';
import 'package:immich_mobile/entities/store.entity.dart' as db_store;
@RoutePage()
class ShareIntentPage extends HookConsumerWidget {
const ShareIntentPage({super.key, required this.attachments});
final List<ShareIntentAttachment> attachments;
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentEndpoint =
db_store.Store.get(db_store.StoreKey.serverEndpoint);
final candidates = ref.watch(shareIntentUploadProvider);
final isUploaded = useState(false);
void removeAttachment(ShareIntentAttachment attachment) {
ref.read(shareIntentUploadProvider.notifier).removeAttachment(attachment);
}
void addAttachments(List<ShareIntentAttachment> attachments) {
ref.read(shareIntentUploadProvider.notifier).addAttachments(attachments);
}
void upload() async {
for (final attachment in candidates) {
await ref
.read(shareIntentUploadProvider.notifier)
.upload(attachment.file);
}
isUploaded.value = true;
}
bool isSelected(ShareIntentAttachment attachment) {
return candidates.contains(attachment);
}
void toggleSelection(ShareIntentAttachment attachment) {
if (isSelected(attachment)) {
removeAttachment(attachment);
} else {
addAttachments([attachment]);
}
}
return Scaffold(
appBar: AppBar(
title: Column(
children: [
const Text('upload_to_immich').tr(
args: [
candidates.length.toString(),
],
),
Text(
currentEndpoint,
style: context.textTheme.labelMedium?.copyWith(
color: context.colorScheme.onSurface.withAlpha(200),
),
),
],
),
),
body: ListView.builder(
itemCount: attachments.length,
itemBuilder: (context, index) {
final attachment = attachments[index];
final target = candidates.firstWhere(
(element) => element.id == attachment.id,
orElse: () => attachment,
);
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 4.0,
horizontal: 16,
),
child: LargeLeadingTile(
onTap: () => toggleSelection(attachment),
disabled: isUploaded.value,
selected: isSelected(attachment),
leading: Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: attachment.isImage
? Image.file(
attachment.file,
width: 64,
height: 64,
fit: BoxFit.cover,
)
: const SizedBox(
width: 64,
height: 64,
child: Center(
child: Icon(
Icons.videocam,
color: Colors.white,
),
),
),
),
if (attachment.isImage)
const Positioned(
top: 8,
right: 8,
child: Icon(
Icons.image,
color: Colors.white,
size: 20,
shadows: [
Shadow(
offset: Offset(0, 0),
blurRadius: 8.0,
color: Colors.black45,
),
],
),
),
],
),
title: Text(
attachment.fileName,
style: context.textTheme.titleSmall,
),
subtitle: Text(
attachment.fileSize,
style: context.textTheme.labelLarge,
),
trailing: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: UploadStatusIcon(
selected: isSelected(attachment),
status: target.status,
progress: target.uploadProgress,
),
),
),
);
},
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
height: 48,
child: ElevatedButton(
onPressed: isUploaded.value ? null : upload,
child: isUploaded.value
? UploadingText(candidates: candidates)
: const Text('upload').tr(),
),
),
),
),
);
}
}
class UploadingText extends StatelessWidget {
const UploadingText({super.key, required this.candidates});
final List<ShareIntentAttachment> candidates;
@override
Widget build(BuildContext context) {
final uploadedCount = candidates.where((element) {
return element.status == UploadStatus.complete;
}).length;
return const Text("shared_intent_upload_button_progress_text")
.tr(args: [uploadedCount.toString(), candidates.length.toString()]);
}
}
class UploadStatusIcon extends StatelessWidget {
const UploadStatusIcon({
super.key,
required this.status,
required this.selected,
this.progress = 0,
});
final UploadStatus status;
final double progress;
final bool selected;
@override
Widget build(BuildContext context) {
if (!selected) {
return Icon(
Icons.check_circle_outline_rounded,
color: context.colorScheme.onSurface.withAlpha(100),
semanticLabel: 'not_selected'.tr(),
);
}
final statusIcon = switch (status) {
UploadStatus.enqueued => Icon(
Icons.check_circle_rounded,
color: context.primaryColor,
semanticLabel: 'enqueued'.tr(),
),
UploadStatus.running => Stack(
alignment: AlignmentDirectional.center,
children: [
SizedBox(
width: 40,
height: 40,
child: TweenAnimationBuilder(
tween: Tween<double>(begin: 0.0, end: progress),
duration: const Duration(milliseconds: 500),
builder: (context, value, _) => CircularProgressIndicator(
backgroundColor: context.colorScheme.surfaceContainerLow,
strokeWidth: 3,
value: value,
semanticsLabel: 'uploading'.tr(),
),
),
),
Text(
(progress * 100).toStringAsFixed(0),
style: context.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
UploadStatus.complete => Icon(
Icons.check_circle_rounded,
color: Colors.green,
semanticLabel: 'completed'.tr(),
),
UploadStatus.notFound || UploadStatus.failed => Icon(
Icons.error_rounded,
color: Colors.red,
semanticLabel: 'failed'.tr(),
),
UploadStatus.canceled => Icon(
Icons.cancel_rounded,
color: Colors.red,
semanticLabel: 'canceled'.tr(),
),
UploadStatus.waitingtoRetry || UploadStatus.paused => Icon(
Icons.pause_circle_rounded,
color: context.primaryColor,
semanticLabel: 'paused'.tr(),
),
};
return statusIcon;
}
}
@@ -0,0 +1,143 @@
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/share_intent_service.dart';
import 'package:immich_mobile/services/upload.service.dart';
final shareIntentUploadProvider = StateNotifierProvider<
ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
((ref) => ShareIntentUploadStateNotifier(
ref.watch(appRouterProvider),
ref.watch(uploadServiceProvider),
ref.watch(shareIntentServiceProvider),
)),
);
class ShareIntentUploadStateNotifier
extends StateNotifier<List<ShareIntentAttachment>> {
final AppRouter router;
final UploadService _uploadService;
final ShareIntentService _shareIntentService;
ShareIntentUploadStateNotifier(
this.router,
this._uploadService,
this._shareIntentService,
) : super([]) {
_uploadService.onUploadStatus = _uploadStatusCallback;
_uploadService.onTaskProgress = _taskProgressCallback;
}
void init() {
_shareIntentService.onSharedMedia = onSharedMedia;
_shareIntentService.init();
}
void onSharedMedia(List<ShareIntentAttachment> attachments) {
router.removeWhere((route) => route.name == "ShareIntentRoute");
clearAttachments();
addAttachments(attachments);
router.push(ShareIntentRoute(attachments: attachments));
}
void addAttachments(List<ShareIntentAttachment> attachments) {
if (attachments.isEmpty) {
return;
}
state = [...state, ...attachments];
}
void removeAttachment(ShareIntentAttachment attachment) {
final updatedState =
state.where((element) => element != attachment).toList();
if (updatedState.length != state.length) {
state = updatedState;
}
}
void clearAttachments() {
if (state.isEmpty) {
return;
}
state = [];
}
void _updateUploadStatus(TaskStatusUpdate task, TaskStatus status) async {
if (status == TaskStatus.canceled) {
return;
}
final taskId = task.task.taskId;
final uploadStatus = switch (task.status) {
TaskStatus.complete => UploadStatus.complete,
TaskStatus.failed => UploadStatus.failed,
TaskStatus.canceled => UploadStatus.canceled,
TaskStatus.enqueued => UploadStatus.enqueued,
TaskStatus.running => UploadStatus.running,
TaskStatus.paused => UploadStatus.paused,
TaskStatus.notFound => UploadStatus.notFound,
TaskStatus.waitingToRetry => UploadStatus.waitingtoRetry
};
state = [
for (final attachment in state)
if (attachment.id == taskId.toInt())
attachment.copyWith(status: uploadStatus)
else
attachment,
];
}
void _uploadStatusCallback(TaskStatusUpdate update) {
_updateUploadStatus(update, update.status);
switch (update.status) {
case TaskStatus.complete:
if (update.responseStatusCode == 200) {
if (kDebugMode) {
debugPrint("[COMPLETE] ${update.task.taskId} - DUPLICATE");
}
} else {
if (kDebugMode) {
debugPrint("[COMPLETE] ${update.task.taskId}");
}
}
break;
default:
break;
}
}
void _taskProgressCallback(TaskProgressUpdate update) {
// Ignore if the task is cancled or completed
if (update.progress == downloadFailed ||
update.progress == downloadCompleted) {
return;
}
final taskId = update.task.taskId;
state = [
for (final attachment in state)
if (attachment.id == taskId.toInt())
attachment.copyWith(uploadProgress: update.progress)
else
attachment,
];
}
Future<void> upload(File file) {
return _uploadService.upload(file);
}
Future<bool> cancelUpload(String id) {
return _uploadService.cancelUpload(id);
}
}
@@ -0,0 +1,63 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/share_handler.interface.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:share_handler/share_handler.dart';
final shareHandlerRepositoryProvider = Provider(
(ref) => ShareHandlerRepository(),
);
class ShareHandlerRepository implements IShareHandlerRepository {
ShareHandlerRepository();
@override
void Function(List<ShareIntentAttachment> attachments)? onSharedMedia;
@override
Future<void> init() async {
final handler = ShareHandlerPlatform.instance;
final media = await handler.getInitialSharedMedia();
if (media != null && media.attachments != null) {
onSharedMedia?.call(_buildPayload(media.attachments!));
}
handler.sharedMediaStream.listen((SharedMedia media) {
if (media.attachments != null) {
onSharedMedia?.call(_buildPayload(media.attachments!));
}
});
}
List<ShareIntentAttachment> _buildPayload(
List<SharedAttachment?> attachments,
) {
final payload = <ShareIntentAttachment>[];
for (final attachment in attachments) {
if (attachment == null) {
continue;
}
final type = attachment.type == SharedAttachmentType.image
? ShareIntentAttachmentType.image
: ShareIntentAttachmentType.video;
final fileLength = File(attachment.path).lengthSync();
payload.add(
ShareIntentAttachment(
path: attachment.path,
type: type,
status: UploadStatus.enqueued,
uploadProgress: 0.0,
fileLength: fileLength,
),
);
}
return payload;
}
}
@@ -0,0 +1,42 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/upload.interface.dart';
import 'package:immich_mobile/utils/upload.dart';
final uploadRepositoryProvider = Provider((ref) => UploadRepository());
class UploadRepository implements IUploadRepository {
@override
void Function(TaskStatusUpdate)? onUploadStatus;
@override
void Function(TaskProgressUpdate)? onTaskProgress;
UploadRepository() {
FileDownloader().registerCallbacks(
group: uploadGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
}
@override
Future<bool> upload(UploadTask task) {
return FileDownloader().enqueue(task);
}
@override
Future<void> deleteAllTrackingRecords() {
return FileDownloader().database.deleteAllRecords();
}
@override
Future<bool> cancel(String id) {
return FileDownloader().cancelTaskWithId(id);
}
@override
Future<void> deleteRecordsWithIds(List<String> ids) {
return FileDownloader().database.deleteRecordsWithIds(ids);
}
}
+6
View File
@@ -8,6 +8,7 @@ import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/pages/backup/album_preview.page.dart';
import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart';
import 'package:immich_mobile/pages/backup/backup_controller.page.dart';
@@ -57,6 +58,7 @@ import 'package:immich_mobile/pages/library/partner/partner.page.dart';
import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart';
import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart';
import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart';
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/routing/auth_guard.dart';
@@ -277,6 +279,10 @@ class AppRouter extends RootStackRouter {
page: NativeVideoViewerRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: ShareIntentRoute.page,
guards: [_authGuard, _duplicateGuard],
),
];
}
+50 -23
View File
@@ -136,15 +136,10 @@ class AlbumAssetSelectionRouteArgs {
/// generated route for
/// [AlbumOptionsPage]
class AlbumOptionsRoute extends PageRouteInfo<AlbumOptionsRouteArgs> {
AlbumOptionsRoute({
Key? key,
List<PageRouteInfo>? children,
}) : super(
class AlbumOptionsRoute extends PageRouteInfo<void> {
const AlbumOptionsRoute({List<PageRouteInfo>? children})
: super(
AlbumOptionsRoute.name,
args: AlbumOptionsRouteArgs(
key: key,
),
initialChildren: children,
);
@@ -153,25 +148,11 @@ class AlbumOptionsRoute extends PageRouteInfo<AlbumOptionsRouteArgs> {
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<AlbumOptionsRouteArgs>();
return AlbumOptionsPage(
key: args.key,
);
return const AlbumOptionsPage();
},
);
}
class AlbumOptionsRouteArgs {
const AlbumOptionsRouteArgs({this.key});
final Key? key;
@override
String toString() {
return 'AlbumOptionsRouteArgs{key: $key}';
}
}
/// generated route for
/// [AlbumPreviewPage]
class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> {
@@ -1453,6 +1434,52 @@ class SettingsSubRouteArgs {
}
}
/// generated route for
/// [ShareIntentPage]
class ShareIntentRoute extends PageRouteInfo<ShareIntentRouteArgs> {
ShareIntentRoute({
Key? key,
required List<ShareIntentAttachment> attachments,
List<PageRouteInfo>? children,
}) : super(
ShareIntentRoute.name,
args: ShareIntentRouteArgs(
key: key,
attachments: attachments,
),
initialChildren: children,
);
static const String name = 'ShareIntentRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<ShareIntentRouteArgs>();
return ShareIntentPage(
key: args.key,
attachments: args.attachments,
);
},
);
}
class ShareIntentRouteArgs {
const ShareIntentRouteArgs({
this.key,
required this.attachments,
});
final Key? key;
final List<ShareIntentAttachment> attachments;
@override
String toString() {
return 'ShareIntentRouteArgs{key: $key, attachments: $attachments}';
}
}
/// generated route for
/// [SharedLinkEditPage]
class SharedLinkEditRoute extends PageRouteInfo<SharedLinkEditRouteArgs> {
@@ -0,0 +1,23 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/repositories/share_handler.repository.dart';
final shareIntentServiceProvider = Provider(
(ref) => ShareIntentService(
ref.watch(shareHandlerRepositoryProvider),
),
);
class ShareIntentService {
final ShareHandlerRepository shareHandlerRepository;
void Function(List<ShareIntentAttachment> attachments)? onSharedMedia;
ShareIntentService(
this.shareHandlerRepository,
);
void init() {
shareHandlerRepository.onSharedMedia = onSharedMedia;
shareHandlerRepository.init();
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/upload.interface.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/upload.dart';
import 'package:path/path.dart';
// import 'package:logging/logging.dart';
final uploadServiceProvider = Provider(
(ref) => UploadService(
ref.watch(uploadRepositoryProvider),
),
);
class UploadService {
final IUploadRepository _uploadRepository;
// final Logger _log = Logger("UploadService");
void Function(TaskStatusUpdate)? onUploadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
UploadService(
this._uploadRepository,
) {
_uploadRepository.onUploadStatus = _onUploadCallback;
_uploadRepository.onTaskProgress = _onTaskProgressCallback;
}
void _onTaskProgressCallback(TaskProgressUpdate update) {
onTaskProgress?.call(update);
}
void _onUploadCallback(TaskStatusUpdate update) {
onUploadStatus?.call(update);
}
Future<bool> cancelUpload(String id) {
return FileDownloader().cancelTaskWithId(id);
}
Future<void> upload(File file) async {
final task = await _buildUploadTask(
hash(file.path).toString(),
file,
);
await _uploadRepository.upload(task);
}
Future<UploadTask> _buildUploadTask(
String id,
File file, {
Map<String, String>? fields,
}) async {
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final url = Uri.parse('$serverEndpoint/assets').toString();
final headers = ApiService.getRequestHeaders();
final deviceId = Store.get(StoreKey.deviceId);
final (baseDirectory, directory, filename) =
await Task.split(filePath: file.path);
final stats = await file.stat();
final fileCreatedAt = stats.changed;
final fileModifiedAt = stats.modified;
final fieldsMap = {
'filename': filename,
'deviceAssetId': id,
'deviceId': deviceId,
'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(),
'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(),
'isFavorite': 'false',
'duration': '0',
if (fields != null) ...fields,
};
return UploadTask(
taskId: id,
httpRequestMethod: 'POST',
url: url,
headers: headers,
filename: filename,
fields: fieldsMap,
baseDirectory: baseDirectory,
directory: directory,
fileField: 'assetData',
group: uploadGroup,
updates: Updates.statusAndProgress,
);
}
}
+9
View File
@@ -1,3 +1,5 @@
import 'dart:math';
String formatBytes(int bytes) {
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
@@ -14,3 +16,10 @@ String formatBytes(int bytes) {
return "${remainder.toStringAsFixed(magnitude == 0 ? 0 : 1)} ${units[magnitude]}";
}
String formatHumanReadableBytes(int bytes, int decimals) {
if (bytes <= 0) return "0 B";
const suffixes = ["B", "KB", "MB", "GB", "TB"];
var i = (log(bytes) / log(1024)).floor();
return '${(bytes / pow(1024, i)).toStringAsFixed(decimals)} ${suffixes[i]}';
}
+1
View File
@@ -0,0 +1 @@
const uploadGroup = 'upload_group';
@@ -206,7 +206,7 @@ class AlbumViewerAppbar extends HookConsumerWidget
),
ListTile(
leading: const Icon(Icons.settings_rounded),
onTap: () => context.navigateTo(AlbumOptionsRoute()),
onTap: () => context.navigateTo(const AlbumOptionsRoute()),
title: const Text(
"translated_text_options",
style: TextStyle(fontWeight: FontWeight.w500),