mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-01-23 01:06:21 +01:00
Manage shares
This commit is contained in:
parent
3b6c679f04
commit
85daba9786
13 changed files with 965 additions and 13 deletions
202
lib/bloc/list_sharing.dart
Normal file
202
lib/bloc/list_sharing.dart
Normal 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");
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
27
lib/use_case/find_file.dart
Normal file
27
lib/use_case/find_file.dart
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
16
lib/use_case/remove_share.dart
Normal file
16
lib/use_case/remove_share.dart
Normal 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;
|
||||
}
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
|
|
266
lib/widget/shared_file_viewer.dart
Normal file
266
lib/widget/shared_file_viewer.dart
Normal 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,
|
||||
}
|
251
lib/widget/sharing_browser.dart
Normal file
251
lib/widget/sharing_browser.dart
Normal 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");
|
||||
}
|
Loading…
Reference in a new issue