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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user