mirror of
https://github.com/immich-app/immich.git
synced 2026-01-23 20:27:21 -05:00
* feat: bring back manual backup * expose iCloud retrieval progress * wip * unify http upload method, check for connectivity on iOS * handle LivePhotos progress * feat: speed calculation * wip * better upload detail page * handle error * handle error * pr feedback * feat: share intent upload * feat: manual upload * feat: manual upload progress * chore: styling * refactor * refactor * remove unused logs * fix: background android backup * feat: add error section * remove complete section * remove empty state and prevent slot jumps * more refactor * fix: background test * chore: add metadata to foreground upload * fix: email and name get reset in auth provider * pr feedback * remove version check for metadata field in upload payload * chore: fix unit test --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
210 lines
7.5 KiB
Dart
210 lines
7.5 KiB
Dart
import 'package:auto_route/auto_route.dart';
|
|
import 'package:easy_localization/easy_localization.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:immich_mobile/entities/store.entity.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/routing/router.dart';
|
|
import 'package:immich_mobile/utils/url_helper.dart';
|
|
|
|
@RoutePage()
|
|
class ShareIntentPage extends ConsumerWidget {
|
|
const ShareIntentPage({super.key, required this.attachments});
|
|
|
|
final List<ShareIntentAttachment> attachments;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final currentEndpoint = getServerUrl() ?? '--';
|
|
final candidates = ref.watch(shareIntentUploadProvider);
|
|
|
|
final isUploading = candidates.any((candidate) => candidate.status == UploadStatus.running);
|
|
final isUploaded =
|
|
candidates.isNotEmpty &&
|
|
candidates.every(
|
|
(candidate) => candidate.status == UploadStatus.complete || candidate.status == UploadStatus.failed,
|
|
);
|
|
|
|
void removeAttachment(ShareIntentAttachment attachment) {
|
|
ref.read(shareIntentUploadProvider.notifier).removeAttachment(attachment);
|
|
}
|
|
|
|
void addAttachments(List<ShareIntentAttachment> attachments) {
|
|
ref.read(shareIntentUploadProvider.notifier).addAttachments(attachments);
|
|
}
|
|
|
|
void upload() async {
|
|
final files = candidates.map((candidate) => candidate.file).toList();
|
|
await ref.read(shareIntentUploadProvider.notifier).uploadAll(files);
|
|
}
|
|
|
|
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(namedArgs: {'count': candidates.length.toString()}),
|
|
Text(
|
|
currentEndpoint,
|
|
style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)),
|
|
),
|
|
],
|
|
),
|
|
leading: IconButton(
|
|
onPressed: () {
|
|
context.navigateTo(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute());
|
|
},
|
|
icon: const Icon(Icons.arrow_back),
|
|
),
|
|
),
|
|
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: isUploading || isUploaded,
|
|
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: (isUploading || isUploaded) ? null : upload,
|
|
child: (isUploading || isUploaded) ? 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(namedArgs: {'current': uploadedCount.toString(), 'total': 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.failed => Icon(Icons.error_rounded, color: Colors.red, semanticLabel: 'failed'.tr()),
|
|
};
|
|
|
|
return statusIcon;
|
|
}
|
|
}
|