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:
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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!,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]}';
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user