Manage shares

This commit is contained in:
Ming Ming 2021-10-07 04:32:36 +08:00
parent 3b6c679f04
commit 85daba9786
13 changed files with 965 additions and 13 deletions

202
lib/bloc/list_sharing.dart Normal file
View file

@ -0,0 +1,202 @@
import 'package:bloc/bloc.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/entity/share/data_source.dart';
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/use_case/find_file.dart';
class ListSharingItem {
ListSharingItem(this.share, this.file);
final Share share;
final File file;
}
abstract class ListSharingBlocEvent {
const ListSharingBlocEvent();
}
class ListSharingBlocQuery extends ListSharingBlocEvent {
const ListSharingBlocQuery(this.account);
@override
toString() {
return "$runtimeType {"
"account: $account, "
"}";
}
final Account account;
}
class _ListSharingBlocShareRemoved extends ListSharingBlocEvent {
const _ListSharingBlocShareRemoved(this.share);
@override
toString() {
return "$runtimeType {"
"share: $share, "
"}";
}
final Share share;
}
abstract class ListSharingBlocState {
const ListSharingBlocState(this.account, this.items);
@override
toString() {
return "$runtimeType {"
"account: $account, "
"items: List {length: ${items.length}}, "
"}";
}
final Account? account;
final List<ListSharingItem> items;
}
class ListSharingBlocInit extends ListSharingBlocState {
ListSharingBlocInit() : super(null, const []);
}
class ListSharingBlocLoading extends ListSharingBlocState {
const ListSharingBlocLoading(Account? account, List<ListSharingItem> items)
: super(account, items);
}
class ListSharingBlocSuccess extends ListSharingBlocState {
const ListSharingBlocSuccess(Account? account, List<ListSharingItem> items)
: super(account, items);
ListSharingBlocSuccess copyWith({
Account? account,
List<ListSharingItem>? items,
}) =>
ListSharingBlocSuccess(
account ?? this.account,
items ?? this.items,
);
}
class ListSharingBlocFailure extends ListSharingBlocState {
const ListSharingBlocFailure(
Account? account, List<ListSharingItem> items, this.exception)
: super(account, items);
@override
toString() {
return "$runtimeType {"
"super: ${super.toString()}, "
"exception: $exception, "
"}";
}
ListSharingBlocFailure copyWith({
Account? account,
List<ListSharingItem>? items,
dynamic exception,
}) =>
ListSharingBlocFailure(
account ?? this.account,
items ?? this.items,
exception ?? this.exception,
);
final dynamic exception;
}
/// List all shares from a given file
class ListSharingBloc extends Bloc<ListSharingBlocEvent, ListSharingBlocState> {
ListSharingBloc() : super(ListSharingBlocInit()) {
_shareRemovedListener.begin();
}
static ListSharingBloc of(Account account) {
final id =
"${account.scheme}://${account.username}@${account.address}?${account.roots.join('&')}";
try {
_log.fine("[of] Resolving bloc for '$id'");
return KiwiContainer().resolve<ListSharingBloc>("ListSharingBloc($id)");
} catch (_) {
// no created instance for this account, make a new one
_log.info("[of] New bloc instance for account: $account");
final bloc = ListSharingBloc();
KiwiContainer().registerInstance<ListSharingBloc>(bloc,
name: "ListSharingBloc($id)");
return bloc;
}
}
@override
mapEventToState(ListSharingBlocEvent event) async* {
_log.info("[mapEventToState] $event");
if (event is ListSharingBlocQuery) {
yield* _onEventQuery(event);
} else if (event is _ListSharingBlocShareRemoved) {
yield* _onEventShareRemoved(event);
}
}
Stream<ListSharingBlocState> _onEventQuery(ListSharingBlocQuery ev) async* {
try {
yield ListSharingBlocLoading(ev.account, state.items);
yield ListSharingBlocSuccess(ev.account, await _query(ev));
} catch (e, stackTrace) {
_log.severe("[_onEventQuery] Exception while request", e, stackTrace);
yield ListSharingBlocFailure(ev.account, state.items, e);
}
}
Stream<ListSharingBlocState> _onEventShareRemoved(
_ListSharingBlocShareRemoved ev) async* {
if (state is! ListSharingBlocSuccess && state is! ListSharingBlocFailure) {
return;
}
final newItems = List.of(state.items)
.where((element) => !identical(element.share, ev.share))
.toList();
// i love hacks :)
yield (state as dynamic).copyWith(
items: newItems,
) as ListSharingBlocState;
}
void _onShareRemovedEvent(ShareRemovedEvent ev) {
add(_ListSharingBlocShareRemoved(ev.share));
}
Future<List<ListSharingItem>> _query(ListSharingBlocQuery ev) async {
final shareRepo = ShareRepo(ShareRemoteDataSource());
final shares = await shareRepo.listAll(ev.account);
final futures = shares.map((e) async {
if (!file_util.isSupportedMime(e.mimeType)) {
return null;
}
if (ev.account.roots
.every((r) => r.isNotEmpty && !e.path.startsWith("/$r/"))) {
// ignore files not under root dirs
return null;
}
try {
final file = await FindFile()(ev.account, e.itemSource);
return ListSharingItem(e, file);
} catch (_) {
_log.warning("[_query] File not found: ${e.itemSource}");
return null;
}
});
return (await Future.wait(futures)).whereType<ListSharingItem>().toList();
}
late final _shareRemovedListener =
AppEventListener<ShareRemovedEvent>(_onShareRemovedEvent);
static final _log = Logger("bloc.list_share.ListSharingBloc");
}

View file

@ -3,8 +3,9 @@ import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
bool isSupportedFormat(File file) =>
_supportedFormatMimes.contains(file.contentType);
bool isSupportedMime(String mime) => _supportedFormatMimes.contains(mime);
bool isSupportedFormat(File file) => isSupportedMime(file.contentType ?? "");
bool isSupportedImageFormat(File file) =>
isSupportedFormat(file) && file.contentType?.startsWith("image/") == true;

View file

@ -1,6 +1,7 @@
import 'package:equatable/equatable.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:path/path.dart' as path_util;
enum ShareType {
user,
@ -54,11 +55,42 @@ extension ShareTypeExtension on ShareType {
}
}
enum ShareItemType {
file,
folder,
}
extension ShareItemTypeExtension on ShareItemType {
static ShareItemType fromValue(String itemTypeVal) {
switch (itemTypeVal) {
case "file":
return ShareItemType.file;
case "folder":
return ShareItemType.folder;
default:
throw ArgumentError("Invalid itemType: $itemTypeVal");
}
}
String toValue() {
switch (this) {
case ShareItemType.file:
return "file";
case ShareItemType.folder:
return "folder";
}
}
}
class Share with EquatableMixin {
Share({
required this.id,
required this.path,
required this.shareType,
required this.stime,
required this.path,
required this.itemType,
required this.mimeType,
required this.itemSource,
required this.shareWith,
required this.shareWithDisplayName,
this.url,
@ -68,8 +100,12 @@ class Share with EquatableMixin {
toString() {
return "$runtimeType {"
"id: $id, "
"path: $path, "
"shareType: $shareType, "
"stime: $stime, "
"path: $path, "
"itemType: $itemType, "
"mimeType: $mimeType, "
"itemSource: $itemSource, "
"shareWith: $shareWith, "
"shareWithDisplayName: $shareWithDisplayName, "
"url: $url, "
@ -79,21 +115,34 @@ class Share with EquatableMixin {
@override
get props => [
id,
path,
shareType,
stime,
path,
itemType,
mimeType,
itemSource,
shareWith,
shareWithDisplayName,
url,
];
// see: https://doc.owncloud.com/server/latest/developer_manual/core/apis/ocs-share-api.html#response-attributes-2
final String id;
final String path;
final ShareType shareType;
final DateTime stime;
final String path;
final ShareItemType itemType;
final String mimeType;
final int itemSource;
final String? shareWith;
final String shareWithDisplayName;
final String? url;
}
extension ShareExtension on Share {
String get filename => path_util.basename(path);
}
class ShareRepo {
ShareRepo(this.dataSrc);
@ -105,6 +154,9 @@ class ShareRepo {
Future<List<Share>> listDir(Account account, File dir) =>
dataSrc.listDir(account, dir);
/// See [ShareDataSource.listAll]
Future<List<Share>> listAll(Account account) => dataSrc.listAll(account);
/// See [ShareDataSource.create]
Future<Share> create(Account account, File file, String shareWith) =>
dataSrc.create(account, file, shareWith);
@ -131,6 +183,9 @@ abstract class ShareDataSource {
/// List all shares from a given directory
Future<List<Share>> listDir(Account account, File dir);
/// List all shares from a given user
Future<List<Share>> listAll(Account account);
/// Share a file/folder with a user
Future<Share> create(Account account, File file, String shareWith);

View file

@ -28,6 +28,13 @@ class ShareRemoteDataSource implements ShareDataSource {
return _onListResult(response);
}
@override
listAll(Account account) async {
_log.info("[listAll] $account");
final response = await Api(account).ocs().filesSharing().shares().get();
return _onListResult(response);
}
@override
create(Account account, File file, String shareWith) async {
_log.info("[create] Share '${file.path}' with '$shareWith'");
@ -116,10 +123,16 @@ class _ShareParser {
Share parseSingle(JsonObj json) {
final shareType = ShareTypeExtension.fromValue(json["share_type"]);
final itemType = ShareItemTypeExtension.fromValue(json["item_type"]);
return Share(
id: json["id"],
path: json["path"],
shareType: shareType,
stime:
DateTime.fromMillisecondsSinceEpoch(json["stime"] * 1000),
path: json["path"],
itemType: itemType,
mimeType: json["mimetype"],
itemSource: json["item_source"],
// when shared with a password protected link, shareWith somehow contains
// the password, which doesn't make sense. We set it to null instead
shareWith: shareType == ShareType.publicLink ? null : json["share_with"],

View file

@ -6,6 +6,7 @@ import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/metadata_task_manager.dart';
import 'package:nc_photos/pref.dart';
@ -85,6 +86,13 @@ class FileMovedEvent {
final String destination;
}
class ShareRemovedEvent {
const ShareRemovedEvent(this.account, this.share);
final Account account;
final Share share;
}
class ThemeChangedEvent {}
class LanguageChangedEvent {}

View file

@ -856,6 +856,36 @@
"description": "Create a password protected share link on server and share it"
},
"shareMethodPasswordLinkDescription": "Create a new password protected link on the server",
"collectionSharingLabel": "Sharing",
"@collectionSharingLabel": {
"description": "List items being shared by the current account"
},
"fileLastSharedDescription": "Last shared on {date}",
"@fileLastSharedDescription": {
"description": "The date when this file is last shared. The date string is formatted according to the current locale",
"placeholders": {
"date": {
"example": "Jan 1, 2021"
}
}
},
"sharedWithLabel": "Shared with",
"@sharedWithLabel": {
"description": "A list of users or links where this file is sharing with"
},
"unshareTooltip": "Unshare",
"@unshareTooltip": {
"description": "Remove a share"
},
"unshareSuccessNotification": "Removed share",
"@unshareSuccessNotification": {
"description": "Removed a share"
},
"locationLabel": "Location",
"@locationLabel": {
"description": "Show where the file is located"
},
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
"@errorUnauthenticated": {
"description": "Error message when server responds with HTTP401"

View file

@ -5,7 +5,13 @@
"settingsAlbumPageTitle",
"settingsShowDateInAlbumTitle",
"settingsShowDateInAlbumDescription",
"sortOptionManualLabel"
"sortOptionManualLabel",
"collectionSharingLabel",
"fileLastSharedDescription",
"sharedWithLabel",
"unshareTooltip",
"unshareSuccessNotification",
"locationLabel"
],
"de": [
@ -28,7 +34,13 @@
"shareMethodPublicLinkTitle",
"shareMethodPublicLinkDescription",
"shareMethodPasswordLinkTitle",
"shareMethodPasswordLinkDescription"
"shareMethodPasswordLinkDescription",
"collectionSharingLabel",
"fileLastSharedDescription",
"sharedWithLabel",
"unshareTooltip",
"unshareSuccessNotification",
"locationLabel"
],
"el": [
@ -106,7 +118,13 @@
"shareMethodPublicLinkTitle",
"shareMethodPublicLinkDescription",
"shareMethodPasswordLinkTitle",
"shareMethodPasswordLinkDescription"
"shareMethodPasswordLinkDescription",
"collectionSharingLabel",
"fileLastSharedDescription",
"sharedWithLabel",
"unshareTooltip",
"unshareSuccessNotification",
"locationLabel"
],
"es": [
@ -115,7 +133,13 @@
"settingsAlbumPageTitle",
"settingsShowDateInAlbumTitle",
"settingsShowDateInAlbumDescription",
"sortOptionManualLabel"
"sortOptionManualLabel",
"collectionSharingLabel",
"fileLastSharedDescription",
"sharedWithLabel",
"unshareTooltip",
"unshareSuccessNotification",
"locationLabel"
],
"fr": [
@ -173,7 +197,13 @@
"shareMethodPublicLinkTitle",
"shareMethodPublicLinkDescription",
"shareMethodPasswordLinkTitle",
"shareMethodPasswordLinkDescription"
"shareMethodPasswordLinkDescription",
"collectionSharingLabel",
"fileLastSharedDescription",
"sharedWithLabel",
"unshareTooltip",
"unshareSuccessNotification",
"locationLabel"
],
"ru": [
@ -204,6 +234,12 @@
"shareMethodPublicLinkTitle",
"shareMethodPublicLinkDescription",
"shareMethodPasswordLinkTitle",
"shareMethodPasswordLinkDescription"
"shareMethodPasswordLinkDescription",
"collectionSharingLabel",
"fileLastSharedDescription",
"sharedWithLabel",
"unshareTooltip",
"unshareSuccessNotification",
"locationLabel"
]
}

View file

@ -0,0 +1,27 @@
import 'package:idb_shim/idb_client.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
class FindFile {
/// Find the [File] in the DB by [fileId]
Future<File> call(Account account, int fileId) async {
return await AppDb.use((db) async {
final transaction =
db.transaction(AppDb.fileDbStoreName, idbModeReadOnly);
final store = transaction.objectStore(AppDb.fileDbStoreName);
final index = store.index(AppDbFileDbEntry.indexName);
final List dbItems = await index
.getAll(AppDbFileDbEntry.toNamespacedFileId(account, fileId));
// find the one owned by us
final dbItem = dbItems.firstWhere((element) {
final e = AppDbFileDbEntry.fromJson(element.cast<String, dynamic>());
return file_util.getUserDirName(e.file) == account.username;
});
final dbEntry = AppDbFileDbEntry.fromJson(dbItem.cast<String, dynamic>());
return dbEntry.file;
});
}
}

View file

@ -0,0 +1,16 @@
import 'package:event_bus/event_bus.dart';
import 'package:kiwi/kiwi.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/event/event.dart';
class RemoveShare {
const RemoveShare(this.shareRepo);
Future<void> call(Account account, Share share) async {
await shareRepo.delete(account, share);
KiwiContainer().resolve<EventBus>().fire(ShareRemovedEvent(account, share));
}
final ShareRepo shareRepo;
}

View file

@ -35,6 +35,7 @@ import 'package:nc_photos/widget/pending_albums.dart';
import 'package:nc_photos/widget/people_browser.dart';
import 'package:nc_photos/widget/selectable_item_stream_list_mixin.dart';
import 'package:nc_photos/widget/selection_app_bar.dart';
import 'package:nc_photos/widget/sharing_browser.dart';
import 'package:nc_photos/widget/trashbin_browser.dart';
import 'package:tuple/tuple.dart';
@ -237,6 +238,19 @@ class _HomeAlbumsState extends State<HomeAlbums>
);
}
SelectableItem _buildSharingItem(BuildContext context) {
return _ButtonListItem(
icon: Icons.share_outlined,
label: L10n.global().collectionSharingLabel,
onTap: () {
if (!isSelectionMode) {
Navigator.of(context).pushNamed(SharingBrowser.routeName,
arguments: SharingBrowserArguments(widget.account));
}
},
);
}
SelectableItem _buildShareItem(BuildContext context) {
return _ButtonListItem(
icon: Icons.share_outlined,
@ -444,6 +458,7 @@ class _HomeAlbumsState extends State<HomeAlbums>
}).map((e) => e.item2);
itemStreamListItems = [
_buildPersonItem(context),
_buildSharingItem(context),
if (Lab().enableSharedAlbum) _buildShareItem(context),
_buildArchiveItem(context),
_buildTrashbinItem(context),

View file

@ -22,6 +22,8 @@ import 'package:nc_photos/widget/person_browser.dart';
import 'package:nc_photos/widget/root_picker.dart';
import 'package:nc_photos/widget/settings.dart';
import 'package:nc_photos/widget/setup.dart';
import 'package:nc_photos/widget/shared_file_viewer.dart';
import 'package:nc_photos/widget/sharing_browser.dart';
import 'package:nc_photos/widget/sign_in.dart';
import 'package:nc_photos/widget/slideshow_viewer.dart';
import 'package:nc_photos/widget/splash.dart';
@ -145,6 +147,8 @@ class _MyAppState extends State<MyApp> implements SnackBarHandler {
route ??= _handlePeopleBrowserRoute(settings);
route ??= _handlePersonBrowserRoute(settings);
route ??= _handleSlideshowViewerRoute(settings);
route ??= _handleSharingBrowserRoute(settings);
route ??= _handleSharedFileViewerRoute(settings);
return route;
}
@ -376,6 +380,34 @@ class _MyAppState extends State<MyApp> implements SnackBarHandler {
return null;
}
Route<dynamic>? _handleSharingBrowserRoute(RouteSettings settings) {
try {
if (settings.name == SharingBrowser.routeName &&
settings.arguments != null) {
final args = settings.arguments as SharingBrowserArguments;
return SharingBrowser.buildRoute(args);
}
} catch (e) {
_log.severe(
"[_handleSharingBrowserRoute] Failed while handling route", e);
}
return null;
}
Route<dynamic>? _handleSharedFileViewerRoute(RouteSettings settings) {
try {
if (settings.name == SharedFileViewer.routeName &&
settings.arguments != null) {
final args = settings.arguments as SharedFileViewerArguments;
return SharedFileViewer.buildRoute(args);
}
} catch (e) {
_log.severe(
"[_handleSharedFileViewerRoute] Failed while handling route", e);
}
return null;
}
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
late AppEventListener<ThemeChangedEvent> _themeChangedListener;

View file

@ -0,0 +1,266 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/cache_manager_util.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/entity/share/data_source.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/remove_share.dart';
import 'package:path/path.dart' as path;
class SharedFileViewerArguments {
SharedFileViewerArguments(this.account, this.file, this.shares);
final Account account;
final File file;
final List<Share> shares;
}
/// Handle shares associated with a [File]
class SharedFileViewer extends StatefulWidget {
static const routeName = "/shared-file-viewer";
static Route buildRoute(SharedFileViewerArguments args) => MaterialPageRoute(
builder: (context) => SharedFileViewer.fromArgs(args),
);
const SharedFileViewer({
Key? key,
required this.account,
required this.file,
required this.shares,
}) : super(key: key);
SharedFileViewer.fromArgs(SharedFileViewerArguments args, {Key? key})
: this(
key: key,
account: args.account,
file: args.file,
shares: args.shares,
);
@override
createState() => _SharedFileViewerState();
final Account account;
final File file;
final List<Share> shares;
}
class _SharedFileViewerState extends State<SharedFileViewer> {
@override
build(BuildContext context) {
return AppTheme(
child: Scaffold(
body: _buildContent(context),
),
);
}
Widget _buildContent(BuildContext context) {
final previewUrl = api_util.getFilePreviewUrl(
widget.account,
widget.file,
width: k.photoLargeSize,
height: k.photoLargeSize,
a: true,
);
return CustomScrollView(
slivers: [
SliverAppBar(
title: Text(path.withoutExtension(widget.file.filename)),
pinned: true,
),
SliverToBoxAdapter(
child: SizedBox(
height: 256,
child: FittedBox(
alignment: Alignment.center,
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: CachedNetworkImage(
cacheManager: CoverCacheManager.inst,
imageUrl: previewUrl,
httpHeaders: {
"Authorization":
Api.getAuthorizationHeaderValue(widget.account),
},
fadeInDuration: const Duration(),
filterQuality: FilterQuality.high,
errorWidget: (context, url, error) {
// just leave it empty
return Container();
},
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
L10n.global().locationLabel,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
),
),
),
SliverToBoxAdapter(
child: ListTile(
title: Text(widget.file.strippedPath),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
L10n.global().sharedWithLabel,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildShareItem(context, _shares[index]),
childCount: _shares.length,
),
),
],
);
}
Widget _buildShareItem(BuildContext context, Share share) {
final dateStr = DateFormat(DateFormat.YEAR_ABBR_MONTH_DAY,
Localizations.localeOf(context).languageCode)
.format(share.stime.toLocal());
return ListTile(
title: Text(_getShareTitle(share)),
subtitle: Text(dateStr),
leading: SizedBox(
height: double.infinity,
child: Icon(_getShareIcon(share)),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (share.shareType == ShareType.publicLink)
IconButton(
onPressed: () => _onItemCopyPressed(share),
icon: Tooltip(
message: MaterialLocalizations.of(context).copyButtonLabel,
child: const Icon(Icons.copy_outlined),
),
),
PopupMenuButton<_ItemMenuOption>(
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
itemBuilder: (_) => [
PopupMenuItem(
value: _ItemMenuOption.unshare,
child: Text(L10n.global().unshareTooltip),
),
],
onSelected: (option) =>
_onItemMenuOptionSelected(context, share, option),
),
],
),
);
}
Future<void> _onItemCopyPressed(Share share) async {
await Clipboard.setData(ClipboardData(text: share.url!));
SnackBarManager().showSnackBar(SnackBar(
content: Text(L10n.global().linkCopiedNotification),
duration: k.snackBarDurationNormal,
));
}
void _onItemMenuOptionSelected(
BuildContext context, Share share, _ItemMenuOption option) {
switch (option) {
case _ItemMenuOption.unshare:
_onItemUnsharePressed(context, share);
break;
}
}
Future<void> _onItemUnsharePressed(BuildContext context, Share share) async {
final shareRepo = ShareRepo(ShareRemoteDataSource());
try {
await RemoveShare(shareRepo)(widget.account, share);
SnackBarManager().showSnackBar(SnackBar(
content: Text(L10n.global().unshareSuccessNotification),
duration: k.snackBarDurationNormal,
));
if (_shares.length == 1) {
// removing last share
Navigator.of(context).pop();
} else {
setState(() {
_shares.remove(share);
});
}
} catch (e, stackTrace) {
_log.shout(
"[_onItemUnsharePressed] Failed while RemoveShare", e, stackTrace);
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(e)),
duration: k.snackBarDurationNormal,
));
}
}
IconData? _getShareIcon(Share share) {
switch (share.shareType) {
case ShareType.user:
case ShareType.email:
case ShareType.federatedCloudShare:
return Icons.person_outlined;
case ShareType.group:
case ShareType.circle:
return Icons.group_outlined;
case ShareType.publicLink:
return Icons.link_outlined;
case ShareType.talk:
return Icons.sms_outlined;
default:
return null;
}
}
String _getShareTitle(Share share) {
if (share.shareType == ShareType.publicLink) {
if (share.url!.startsWith(widget.account.url)) {
return share.url!.substring(widget.account.url.length);
} else {
return share.url!;
}
} else {
return share.shareWithDisplayName;
}
}
late final List<Share> _shares = List.of(widget.shares);
static final _log =
Logger("widget.shared_file_viewer._SharedFileViewerState");
}
enum _ItemMenuOption {
unshare,
}

View file

@ -0,0 +1,251 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc/list_sharing.dart';
import 'package:nc_photos/cache_manager_util.dart';
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/widget/empty_list_indicator.dart';
import 'package:nc_photos/widget/shared_file_viewer.dart';
class SharingBrowserArguments {
SharingBrowserArguments(this.account);
final Account account;
}
/// Show a list of all shares associated with this account
class SharingBrowser extends StatefulWidget {
static const routeName = "/sharing-browser";
static Route buildRoute(SharingBrowserArguments args) => MaterialPageRoute(
builder: (context) => SharingBrowser.fromArgs(args),
);
const SharingBrowser({
Key? key,
required this.account,
}) : super(key: key);
SharingBrowser.fromArgs(SharingBrowserArguments args, {Key? key})
: this(
key: key,
account: args.account,
);
@override
createState() => _SharingBrowserState();
final Account account;
}
class _SharingBrowserState extends State<SharingBrowser> {
@override
initState() {
super.initState();
_initBloc();
_shareRemovedListener.begin();
}
@override
dispose() {
_shareRemovedListener.end();
super.dispose();
}
@override
build(BuildContext context) {
return AppTheme(
child: Scaffold(
body: BlocListener<ListSharingBloc, ListSharingBlocState>(
bloc: _bloc,
listener: (context, state) => _onStateChange(context, state),
child: BlocBuilder<ListSharingBloc, ListSharingBlocState>(
bloc: _bloc,
builder: (context, state) => _buildContent(context, state),
),
),
),
);
}
void _initBloc() {
if (_bloc.state is ListSharingBlocInit) {
_log.info("[_initBloc] Initialize bloc");
} else {
// process the current state
WidgetsBinding.instance!.addPostFrameCallback((_) {
setState(() {
_onStateChange(context, _bloc.state);
});
});
}
_reqQuery();
}
Widget _buildContent(BuildContext context, ListSharingBlocState state) {
if ((state is ListSharingBlocSuccess || state is ListSharingBlocFailure) &&
state.items.isEmpty) {
return _buildEmptyContent(context);
} else {
return CustomScrollView(
slivers: [
SliverAppBar(
title: Text(L10n.global().collectionSharingLabel),
floating: true,
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildItem(context, _items[index]),
childCount: _items.length,
),
),
],
);
}
}
Widget _buildEmptyContent(BuildContext context) {
return Column(
children: [
AppBar(
title: Text(L10n.global().collectionSharingLabel),
elevation: 0,
),
Expanded(
child: EmptyListIndicator(
icon: Icons.share_outlined,
text: L10n.global().listEmptyText,
),
),
],
);
}
Widget _buildItem(BuildContext context, List<ListSharingItem> shares) {
const leadingSize = 56.0;
final dateStr = DateFormat(DateFormat.YEAR_ABBR_MONTH_DAY,
Localizations.localeOf(context).languageCode)
.format(shares.first.share.stime.toLocal());
return InkWell(
onTap: () {
Navigator.of(context).pushNamed(SharedFileViewer.routeName,
arguments: SharedFileViewerArguments(
widget.account,
shares.first.file,
shares.map((e) => e.share).toList(),
));
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
shares.first.share.itemType == ShareItemType.folder
? const Icon(
Icons.folder_outlined,
size: leadingSize,
)
: CachedNetworkImage(
width: leadingSize,
height: leadingSize,
cacheManager: ThumbnailCacheManager.inst,
imageUrl: api_util.getFilePreviewUrl(
widget.account, shares.first.file,
width: k.photoThumbSize, height: k.photoThumbSize),
httpHeaders: {
"Authorization":
Api.getAuthorizationHeaderValue(widget.account),
},
fadeInDuration: const Duration(),
filterQuality: FilterQuality.high,
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
shares.first.share.filename,
style: Theme.of(context).textTheme.subtitle1,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
L10n.global().fileLastSharedDescription(dateStr),
style: TextStyle(
color: AppTheme.getSecondaryTextColor(context),
),
),
],
),
),
if (shares.any((element) => element.share.url?.isNotEmpty == true))
const Icon(Icons.link)
],
),
),
);
}
void _onStateChange(BuildContext context, ListSharingBlocState state) {
if (state is ListSharingBlocInit) {
_items = [];
} else if (state is ListSharingBlocSuccess ||
state is ListSharingBlocLoading) {
_transformItems(state.items);
} else if (state is ListSharingBlocFailure) {
_transformItems(state.items);
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(state.exception)),
duration: k.snackBarDurationNormal,
));
}
}
void _onShareRemovedEvent(ShareRemovedEvent ev) {}
void _transformItems(List<ListSharingItem> items) {
// group shares of the same file
final map = <String, List<ListSharingItem>>{};
for (final i in items) {
map[i.share.path] ??= <ListSharingItem>[];
map[i.share.path]!.add(i);
}
// sort the sub-lists
for (final list in map.values) {
list.sort((a, b) => b.share.stime.compareTo(a.share.stime));
}
// then sort the map and convert it to list
_items = map.entries
.sorted((a, b) =>
b.value.first.share.stime.compareTo(a.value.first.share.stime))
.map((e) => e.value)
.toList();
}
void _reqQuery() {
_bloc.add(ListSharingBlocQuery(widget.account));
}
late final _bloc = ListSharingBloc.of(widget.account);
late final _shareRemovedListener =
AppEventListener<ShareRemovedEvent>(_onShareRemovedEvent);
var _items = <List<ListSharingItem>>[];
static final _log = Logger("widget.sharing_browser._SharingBrowserState");
}