✨ Save attachment to album
This commit is contained in:
@ -1,8 +1,15 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gal/gal.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:path/path.dart' show extension;
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:photo_view/photo_view_gallery.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@ -11,13 +18,16 @@ import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/universal_image.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class AttachmentZoomView extends StatefulWidget {
|
||||
final Iterable<SnAttachment> data;
|
||||
final int? initialIndex;
|
||||
final List<String?>? heroTags;
|
||||
|
||||
const AttachmentZoomView({
|
||||
super.key,
|
||||
required this.data,
|
||||
@ -30,11 +40,68 @@ class AttachmentZoomView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AttachmentZoomViewState extends State<AttachmentZoomView> {
|
||||
late final PageController _pageController =
|
||||
PageController(initialPage: widget.initialIndex ?? 0);
|
||||
late final PageController _pageController = PageController(initialPage: widget.initialIndex ?? 0);
|
||||
|
||||
void _updatePage() {
|
||||
setState(() {});
|
||||
setState(() {
|
||||
if (_isCompletedDownload) {
|
||||
setState(() => _isCompletedDownload = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool _isDownloading = false;
|
||||
bool _isCompletedDownload = false;
|
||||
double? _progressOfDownload = 0;
|
||||
|
||||
Future<void> _saveToAlbum(int idx) async {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final item = widget.data.elementAt(idx);
|
||||
final url = sn.getAttachmentUrl(item.rid);
|
||||
|
||||
if (kIsWeb || Platform.isLinux) {
|
||||
await launchUrlString(url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await Gal.hasAccess(toAlbum: true)) {
|
||||
if (!await Gal.requestAccess(toAlbum: true)) return;
|
||||
}
|
||||
|
||||
setState(() => _isDownloading = true);
|
||||
|
||||
var extName = extension(item.name);
|
||||
if (extName.isEmpty) extName = '.png';
|
||||
final imagePath = '${Directory.systemTemp.path}/${item.uuid}$extName';
|
||||
await Dio().download(
|
||||
url,
|
||||
imagePath,
|
||||
onReceiveProgress: (count, total) {
|
||||
setState(() => _progressOfDownload = count / total);
|
||||
},
|
||||
);
|
||||
|
||||
bool isSuccess = false;
|
||||
try {
|
||||
await Gal.putImage(imagePath, album: 'Solar Network');
|
||||
setState(() {
|
||||
isSuccess = true;
|
||||
_isDownloading = false;
|
||||
_isCompletedDownload = isSuccess;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(e);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
context.showSnackbar(
|
||||
'attachmentSaved'.tr(),
|
||||
action: SnackBarAction(
|
||||
label: 'openInAlbum'.tr(),
|
||||
onPressed: () async => Gal.open(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -51,8 +118,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Color get _unFocusColor =>
|
||||
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
|
||||
Color get _unFocusColor => Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -72,207 +138,218 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
|
||||
direction: DismissiblePageDismissDirection.down,
|
||||
backgroundColor: Colors.transparent,
|
||||
isFullScreen: true,
|
||||
child: Stack(
|
||||
children: [
|
||||
Builder(builder: (context) {
|
||||
if (widget.data.length == 1) {
|
||||
final heroTag = widget.heroTags?.first ?? uuid.v4();
|
||||
return Hero(
|
||||
tag: 'attachment-${widget.data.first.rid}-$heroTag',
|
||||
child: PhotoView(
|
||||
key: Key(
|
||||
'attachment-detail-${widget.data.first.rid}-$heroTag'),
|
||||
backgroundDecoration:
|
||||
BoxDecoration(color: Colors.transparent),
|
||||
imageProvider: UniversalImage.provider(
|
||||
sn.getAttachmentUrl(widget.data.first.rid),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return PhotoViewGallery.builder(
|
||||
pageController: _pageController,
|
||||
scrollPhysics: const BouncingScrollPhysics(),
|
||||
builder: (context, idx) {
|
||||
final heroTag = widget.heroTags?.elementAt(idx) ?? uuid.v4();
|
||||
return PhotoViewGalleryPageOptions(
|
||||
imageProvider: UniversalImage.provider(
|
||||
sn.getAttachmentUrl(widget.data.elementAt(idx).rid),
|
||||
),
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: 'attachment-${widget.data.first.rid}-$heroTag',
|
||||
child: Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Builder(builder: (context) {
|
||||
if (widget.data.length == 1) {
|
||||
final heroTag = widget.heroTags?.first ?? uuid.v4();
|
||||
return Hero(
|
||||
tag: 'attachment-${widget.data.first.rid}-$heroTag',
|
||||
child: PhotoView(
|
||||
key: Key('attachment-detail-${widget.data.first.rid}-$heroTag'),
|
||||
backgroundDecoration: BoxDecoration(color: Colors.transparent),
|
||||
imageProvider: UniversalImage.provider(
|
||||
sn.getAttachmentUrl(widget.data.first.rid),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: widget.data.length,
|
||||
loadingBuilder: (context, event) => Center(
|
||||
child: SizedBox(
|
||||
width: 20.0,
|
||||
height: 20.0,
|
||||
child: CircularProgressIndicator(
|
||||
value: event == null
|
||||
? 0
|
||||
: event.cumulativeBytesLoaded /
|
||||
(event.expectedTotalBytes ?? 1),
|
||||
}
|
||||
|
||||
return PhotoViewGallery.builder(
|
||||
pageController: _pageController,
|
||||
scrollPhysics: const BouncingScrollPhysics(),
|
||||
builder: (context, idx) {
|
||||
final heroTag = widget.heroTags?.elementAt(idx) ?? uuid.v4();
|
||||
return PhotoViewGalleryPageOptions(
|
||||
imageProvider: UniversalImage.provider(
|
||||
sn.getAttachmentUrl(widget.data.elementAt(idx).rid),
|
||||
),
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: 'attachment-${widget.data.first.rid}-$heroTag',
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: widget.data.length,
|
||||
loadingBuilder: (context, event) => Center(
|
||||
child: SizedBox(
|
||||
width: 20.0,
|
||||
height: 20.0,
|
||||
child: CircularProgressIndicator(
|
||||
value: event == null ? 0 : event.cumulativeBytesLoaded / (event.expectedTotalBytes ?? 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundDecoration: BoxDecoration(color: Colors.transparent),
|
||||
);
|
||||
}),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
height: 300,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.surface,
|
||||
Colors.transparent,
|
||||
],
|
||||
backgroundDecoration: BoxDecoration(color: Colors.transparent),
|
||||
);
|
||||
}),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
height: 300,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.surface,
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16 + MediaQuery.of(context).padding.bottom,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Builder(builder: (context) {
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
final item = widget.data.elementAt(
|
||||
widget.data.length > 1
|
||||
? _pageController.page?.round() ?? 0
|
||||
: 0,
|
||||
);
|
||||
final account = ud.getAccountFromCache(item.accountId);
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16 + MediaQuery.of(context).padding.bottom,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Builder(builder: (context) {
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
final item = widget.data.elementAt(
|
||||
widget.data.length > 1 ? _pageController.page?.round() ?? 0 : 0,
|
||||
);
|
||||
final account = ud.getAccountFromCache(item.accountId);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (item.accountId > 0)
|
||||
Row(
|
||||
children: [
|
||||
IgnorePointer(
|
||||
child: AccountImage(
|
||||
content: account!.avatar,
|
||||
radius: 19,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: IgnorePointer(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'attachmentUploadBy'.tr(),
|
||||
style:
|
||||
Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
account.nick,
|
||||
style:
|
||||
Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (item.accountId > 0)
|
||||
Row(
|
||||
children: [
|
||||
IgnorePointer(
|
||||
child: AccountImage(
|
||||
content: account!.avatar,
|
||||
radius: 19,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.data.length > 1)
|
||||
IgnorePointer(
|
||||
child: Text(
|
||||
'${(_pageController.page?.round() ?? 0) + 1}/${widget.data.length}',
|
||||
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||
).padding(right: 8),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: IgnorePointer(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'attachmentUploadBy'.tr(),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
account.nick,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(4),
|
||||
IgnorePointer(
|
||||
child: Text(
|
||||
item.alt,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
if (widget.data.length > 1)
|
||||
IgnorePointer(
|
||||
child: Text(
|
||||
'${(_pageController.page?.round() ?? 0) + 1}/${widget.data.length}',
|
||||
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||
).padding(right: 8),
|
||||
),
|
||||
InkWell(
|
||||
onTap: _isDownloading
|
||||
? null
|
||||
: () => _saveToAlbum(widget.data.length > 1 ? _pageController.page?.round() ?? 0 : 0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: !_isDownloading
|
||||
? !_isCompletedDownload
|
||||
? const Icon(Symbols.save_alt)
|
||||
: const Icon(Symbols.download_done)
|
||||
: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
value: _progressOfDownload,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(4),
|
||||
IgnorePointer(
|
||||
child: Text(
|
||||
item.alt,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(2),
|
||||
IgnorePointer(
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
children: [
|
||||
if (item.metadata['exif'] == null)
|
||||
const Gap(2),
|
||||
IgnorePointer(
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
children: [
|
||||
if (item.metadata['exif'] == null)
|
||||
Text(
|
||||
'#${item.rid}',
|
||||
style: metaTextStyle,
|
||||
),
|
||||
if (item.metadata['exif']?['Model'] != null)
|
||||
Text(
|
||||
'attachmentShotOn'.tr(args: [
|
||||
item.metadata['exif']?['Model'],
|
||||
]),
|
||||
style: metaTextStyle,
|
||||
).padding(right: 2),
|
||||
if (item.metadata['exif']?['ShutterSpeed'] != null)
|
||||
Text(
|
||||
item.metadata['exif']?['ShutterSpeed'],
|
||||
style: metaTextStyle,
|
||||
).padding(right: 2),
|
||||
if (item.metadata['exif']?['ISO'] != null)
|
||||
Text(
|
||||
'ISO${item.metadata['exif']?['ISO']}',
|
||||
style: metaTextStyle,
|
||||
).padding(right: 2),
|
||||
if (item.metadata['exif']?['Aperture'] != null)
|
||||
Text(
|
||||
'f/${item.metadata['exif']?['Aperture']}',
|
||||
style: metaTextStyle,
|
||||
).padding(right: 2),
|
||||
if (item.metadata['exif']?['Megapixels'] != null && item.metadata['exif']?['Model'] != null)
|
||||
Text(
|
||||
'${item.metadata['exif']?['Megapixels']}MP',
|
||||
style: metaTextStyle,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'${item.size} Bytes',
|
||||
style: metaTextStyle,
|
||||
),
|
||||
Text(
|
||||
'#${item.rid}',
|
||||
'${item.metadata['width']}x${item.metadata['height']}',
|
||||
style: metaTextStyle,
|
||||
),
|
||||
if (item.metadata['exif']?['Model'] != null)
|
||||
if (item.metadata['ratio'] != null)
|
||||
Text(
|
||||
(item.metadata['ratio'] as num).toStringAsFixed(2),
|
||||
style: metaTextStyle,
|
||||
),
|
||||
Text(
|
||||
'attachmentShotOn'.tr(args: [
|
||||
item.metadata['exif']?['Model'],
|
||||
]),
|
||||
style: metaTextStyle,
|
||||
).padding(right: 2),
|
||||
if (item.metadata['exif']?['ShutterSpeed'] != null)
|
||||
Text(
|
||||
item.metadata['exif']?['ShutterSpeed'],
|
||||
style: metaTextStyle,
|
||||
).padding(right: 2),
|
||||
if (item.metadata['exif']?['ISO'] != null)
|
||||
Text(
|
||||
'ISO${item.metadata['exif']?['ISO']}',
|
||||
style: metaTextStyle,
|
||||
).padding(right: 2),
|
||||
if (item.metadata['exif']?['Aperture'] != null)
|
||||
Text(
|
||||
'f/${item.metadata['exif']?['Aperture']}',
|
||||
style: metaTextStyle,
|
||||
).padding(right: 2),
|
||||
if (item.metadata['exif']?['Megapixels'] != null &&
|
||||
item.metadata['exif']?['Model'] != null)
|
||||
Text(
|
||||
'${item.metadata['exif']?['Megapixels']}MP',
|
||||
style: metaTextStyle,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'${item.size} Bytes',
|
||||
item.mimetype,
|
||||
style: metaTextStyle,
|
||||
),
|
||||
Text(
|
||||
'${item.metadata['width']}x${item.metadata['height']}',
|
||||
style: metaTextStyle,
|
||||
),
|
||||
if (item.metadata['ratio'] != null)
|
||||
Text(
|
||||
(item.metadata['ratio'] as num)
|
||||
.toStringAsFixed(2),
|
||||
style: metaTextStyle,
|
||||
),
|
||||
Text(
|
||||
item.mimetype,
|
||||
style: metaTextStyle,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -311,8 +311,6 @@ class _PostHeadline extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
if (isEnlarge) {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final textScaler = TextScaler.linear(1.5);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
Reference in New Issue
Block a user