mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-02-24 02:18:50 +01:00
Remove obsolete files
This commit is contained in:
parent
05e1aecbf3
commit
c6aced336e
14 changed files with 0 additions and 2272 deletions
|
@ -1,96 +0,0 @@
|
|||
import 'package:bloc/bloc.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.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:np_codegen/np_codegen.dart';
|
||||
import 'package:to_string/to_string.dart';
|
||||
|
||||
part 'list_share.g.dart';
|
||||
|
||||
abstract class ListShareBlocEvent {
|
||||
const ListShareBlocEvent();
|
||||
}
|
||||
|
||||
@toString
|
||||
class ListShareBlocQuery extends ListShareBlocEvent {
|
||||
const ListShareBlocQuery(this.account, this.file);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final Account account;
|
||||
final File file;
|
||||
}
|
||||
|
||||
@toString
|
||||
abstract class ListShareBlocState {
|
||||
const ListShareBlocState(this.account, this.file, this.items);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final Account? account;
|
||||
final File file;
|
||||
final List<Share> items;
|
||||
}
|
||||
|
||||
class ListShareBlocInit extends ListShareBlocState {
|
||||
ListShareBlocInit() : super(null, File(path: ""), const []);
|
||||
}
|
||||
|
||||
class ListShareBlocLoading extends ListShareBlocState {
|
||||
const ListShareBlocLoading(Account? account, File file, List<Share> items)
|
||||
: super(account, file, items);
|
||||
}
|
||||
|
||||
class ListShareBlocSuccess extends ListShareBlocState {
|
||||
const ListShareBlocSuccess(Account? account, File file, List<Share> items)
|
||||
: super(account, file, items);
|
||||
}
|
||||
|
||||
@toString
|
||||
class ListShareBlocFailure extends ListShareBlocState {
|
||||
const ListShareBlocFailure(
|
||||
Account? account, File file, List<Share> items, this.exception)
|
||||
: super(account, file, items);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final dynamic exception;
|
||||
}
|
||||
|
||||
/// List all shares from a given file
|
||||
@npLog
|
||||
class ListShareBloc extends Bloc<ListShareBlocEvent, ListShareBlocState> {
|
||||
ListShareBloc() : super(ListShareBlocInit()) {
|
||||
on<ListShareBlocEvent>(_onEvent);
|
||||
}
|
||||
|
||||
Future<void> _onEvent(
|
||||
ListShareBlocEvent event, Emitter<ListShareBlocState> emit) async {
|
||||
_log.info("[_onEvent] $event");
|
||||
if (event is ListShareBlocQuery) {
|
||||
await _onEventQuery(event, emit);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onEventQuery(
|
||||
ListShareBlocQuery ev, Emitter<ListShareBlocState> emit) async {
|
||||
try {
|
||||
emit(ListShareBlocLoading(ev.account, ev.file, state.items));
|
||||
emit(ListShareBlocSuccess(ev.account, ev.file, await _query(ev)));
|
||||
} catch (e, stackTrace) {
|
||||
_log.severe("[_onEventQuery] Exception while request", e, stackTrace);
|
||||
emit(ListShareBlocFailure(ev.account, ev.file, state.items, e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Share>> _query(ListShareBlocQuery ev) {
|
||||
final shareRepo = ShareRepo(ShareRemoteDataSource());
|
||||
return shareRepo.list(ev.account, ev.file);
|
||||
}
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'list_share.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// NpLogGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$ListShareBlocNpLog on ListShareBloc {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("bloc.list_share.ListShareBloc");
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// ToStringGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$ListShareBlocQueryToString on ListShareBlocQuery {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "ListShareBlocQuery {account: $account, file: ${file.path}}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$ListShareBlocStateToString on ListShareBlocState {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "${objectRuntimeType(this, "ListShareBlocState")} {account: $account, file: ${file.path}, items: [length: ${items.length}]}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$ListShareBlocFailureToString on ListShareBlocFailure {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "ListShareBlocFailure {account: $account, file: ${file.path}, items: [length: ${items.length}], exception: $exception}";
|
||||
}
|
||||
}
|
|
@ -1,111 +0,0 @@
|
|||
import 'package:bloc/bloc.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:kiwi/kiwi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/bloc/bloc_util.dart' as bloc_util;
|
||||
import 'package:nc_photos/entity/sharee.dart';
|
||||
import 'package:nc_photos/entity/sharee/data_source.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:to_string/to_string.dart';
|
||||
|
||||
part 'list_sharee.g.dart';
|
||||
|
||||
abstract class ListShareeBlocEvent {
|
||||
const ListShareeBlocEvent();
|
||||
}
|
||||
|
||||
@toString
|
||||
class ListShareeBlocQuery extends ListShareeBlocEvent {
|
||||
const ListShareeBlocQuery(this.account);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final Account account;
|
||||
}
|
||||
|
||||
@toString
|
||||
abstract class ListShareeBlocState {
|
||||
const ListShareeBlocState(this.account, this.items);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final Account? account;
|
||||
final List<Sharee> items;
|
||||
}
|
||||
|
||||
class ListShareeBlocInit extends ListShareeBlocState {
|
||||
ListShareeBlocInit() : super(null, const []);
|
||||
}
|
||||
|
||||
class ListShareeBlocLoading extends ListShareeBlocState {
|
||||
const ListShareeBlocLoading(Account? account, List<Sharee> items)
|
||||
: super(account, items);
|
||||
}
|
||||
|
||||
class ListShareeBlocSuccess extends ListShareeBlocState {
|
||||
const ListShareeBlocSuccess(Account? account, List<Sharee> items)
|
||||
: super(account, items);
|
||||
}
|
||||
|
||||
@toString
|
||||
class ListShareeBlocFailure extends ListShareeBlocState {
|
||||
const ListShareeBlocFailure(
|
||||
Account? account, List<Sharee> items, this.exception)
|
||||
: super(account, items);
|
||||
|
||||
@override
|
||||
String toString() => _$toString();
|
||||
|
||||
final dynamic exception;
|
||||
}
|
||||
|
||||
/// List all sharees of this account
|
||||
@npLog
|
||||
class ListShareeBloc extends Bloc<ListShareeBlocEvent, ListShareeBlocState> {
|
||||
ListShareeBloc() : super(ListShareeBlocInit()) {
|
||||
on<ListShareeBlocEvent>(_onEvent);
|
||||
}
|
||||
|
||||
static ListShareeBloc of(Account account) {
|
||||
final name = bloc_util.getInstNameForAccount("ListShareeBloc", account);
|
||||
try {
|
||||
_log.fine("[of] Resolving bloc for '$name'");
|
||||
return KiwiContainer().resolve<ListShareeBloc>(name);
|
||||
} catch (_) {
|
||||
// no created instance for this account, make a new one
|
||||
_log.info("[of] New bloc instance for account: $account");
|
||||
final bloc = ListShareeBloc();
|
||||
KiwiContainer().registerInstance<ListShareeBloc>(bloc, name: name);
|
||||
return bloc;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onEvent(
|
||||
ListShareeBlocEvent event, Emitter<ListShareeBlocState> emit) async {
|
||||
_log.info("[_onEvent] $event");
|
||||
if (event is ListShareeBlocQuery) {
|
||||
await _onEventQuery(event, emit);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onEventQuery(
|
||||
ListShareeBlocQuery ev, Emitter<ListShareeBlocState> emit) async {
|
||||
try {
|
||||
emit(ListShareeBlocLoading(ev.account, state.items));
|
||||
emit(ListShareeBlocSuccess(ev.account, await _query(ev)));
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout("[_onEventQuery] Exception while request", e, stackTrace);
|
||||
emit(ListShareeBlocFailure(ev.account, state.items, e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Sharee>> _query(ListShareeBlocQuery ev) {
|
||||
final shareeRepo = ShareeRepo(ShareeRemoteDataSource());
|
||||
return shareeRepo.list(ev.account);
|
||||
}
|
||||
|
||||
static final _log = _$ListShareeBlocNpLog.log;
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'list_sharee.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// NpLogGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$ListShareeBlocNpLog on ListShareeBloc {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("bloc.list_sharee.ListShareeBloc");
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// ToStringGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$ListShareeBlocQueryToString on ListShareeBlocQuery {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "ListShareeBlocQuery {account: $account}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$ListShareeBlocStateToString on ListShareeBlocState {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "${objectRuntimeType(this, "ListShareeBlocState")} {account: $account, items: [length: ${items.length}]}";
|
||||
}
|
||||
}
|
||||
|
||||
extension _$ListShareeBlocFailureToString on ListShareeBlocFailure {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "ListShareeBlocFailure {account: $account, items: [length: ${items.length}], exception: $exception}";
|
||||
}
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
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';
|
||||
|
||||
class ListDirShareItem {
|
||||
const ListDirShareItem(this.file, this.shares);
|
||||
|
||||
/// The File returned contains only fileId and path. If you need other fields,
|
||||
/// you must query the file again
|
||||
final File file;
|
||||
final List<Share> shares;
|
||||
}
|
||||
|
||||
class ListDirShare {
|
||||
const ListDirShare(this.shareRepo);
|
||||
|
||||
/// List all shares from a given dir
|
||||
Future<List<ListDirShareItem>> call(Account account, File dir) async {
|
||||
final shares = await shareRepo.listDir(account, dir);
|
||||
final shareGroups = <int, List<Share>>{};
|
||||
for (final s in shares) {
|
||||
shareGroups[s.itemSource] ??= <Share>[];
|
||||
shareGroups[s.itemSource]!.add(s);
|
||||
}
|
||||
return shareGroups.entries
|
||||
.map((e) => ListDirShareItem(
|
||||
File(
|
||||
path: file_util.unstripPath(account, e.value.first.path),
|
||||
fileId: e.key,
|
||||
),
|
||||
e.value))
|
||||
.toList();
|
||||
}
|
||||
|
||||
final ShareRepo shareRepo;
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -1,25 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'album_browser.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// NpLogGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$_AlbumBrowserStateNpLog on _AlbumBrowserState {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("widget.album_browser._AlbumBrowserState");
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// ToStringGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$_ListItemToString on _ListItem {
|
||||
String _$toString() {
|
||||
// ignore: unnecessary_string_interpolations
|
||||
return "${objectRuntimeType(this, "_ListItem")} {index: $index}";
|
||||
}
|
||||
}
|
|
@ -1,141 +0,0 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/app_localizations.dart';
|
||||
import 'package:nc_photos/cache_manager_util.dart';
|
||||
import 'package:nc_photos/entity/album.dart';
|
||||
import 'package:nc_photos/np_api_util.dart';
|
||||
|
||||
class AlbumBrowserAppBar extends StatelessWidget {
|
||||
const AlbumBrowserAppBar({
|
||||
Key? key,
|
||||
required this.account,
|
||||
required this.album,
|
||||
this.coverPreviewUrl,
|
||||
this.actions,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
return SliverAppBar(
|
||||
floating: true,
|
||||
expandedHeight: 160,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: _getAppBarCover(context, account, coverPreviewUrl),
|
||||
title: Text(
|
||||
album.name,
|
||||
style:
|
||||
TextStyle(color: Theme.of(context).appBarTheme.foregroundColor),
|
||||
),
|
||||
),
|
||||
actions: actions,
|
||||
);
|
||||
}
|
||||
|
||||
final Account account;
|
||||
final Album album;
|
||||
final String? coverPreviewUrl;
|
||||
final List<Widget>? actions;
|
||||
}
|
||||
|
||||
class AlbumBrowserEditAppBar extends StatefulWidget {
|
||||
const AlbumBrowserEditAppBar({
|
||||
Key? key,
|
||||
required this.account,
|
||||
required this.album,
|
||||
this.coverPreviewUrl,
|
||||
this.actions,
|
||||
required this.onDonePressed,
|
||||
required this.onAlbumNameSaved,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
createState() => _AlbumBrowserEditAppBarState();
|
||||
|
||||
final Account account;
|
||||
final Album album;
|
||||
final String? coverPreviewUrl;
|
||||
final List<Widget>? actions;
|
||||
final VoidCallback? onDonePressed;
|
||||
final ValueChanged<String>? onAlbumNameSaved;
|
||||
}
|
||||
|
||||
class _AlbumBrowserEditAppBarState extends State<AlbumBrowserEditAppBar> {
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.album.name);
|
||||
}
|
||||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
return SliverAppBar(
|
||||
floating: true,
|
||||
expandedHeight: 160,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background:
|
||||
_getAppBarCover(context, widget.account, widget.coverPreviewUrl),
|
||||
title: TextFormField(
|
||||
controller: _controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: L10n.global().nameInputHint,
|
||||
),
|
||||
validator: (_) {
|
||||
// use _controller.text here because the value might be wrong if
|
||||
// user scrolled the app bar off screen
|
||||
if (_controller.text.isNotEmpty == true) {
|
||||
return null;
|
||||
} else {
|
||||
return L10n.global().nameInputInvalidEmpty;
|
||||
}
|
||||
},
|
||||
onSaved: (_) {
|
||||
widget.onAlbumNameSaved?.call(_controller.text);
|
||||
},
|
||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(
|
||||
color: Theme.of(context).appBarTheme.foregroundColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.check),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
tooltip: L10n.global().doneButtonTooltip,
|
||||
onPressed: widget.onDonePressed,
|
||||
),
|
||||
actions: widget.actions,
|
||||
);
|
||||
}
|
||||
|
||||
late TextEditingController _controller;
|
||||
}
|
||||
|
||||
Widget? _getAppBarCover(
|
||||
BuildContext context, Account account, String? coverPreviewUrl) {
|
||||
try {
|
||||
if (coverPreviewUrl != null) {
|
||||
return Opacity(
|
||||
opacity: Theme.of(context).brightness == Brightness.light ? 0.25 : 0.35,
|
||||
child: FittedBox(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
fit: BoxFit.cover,
|
||||
child: CachedNetworkImage(
|
||||
cacheManager: CoverCacheManager.inst,
|
||||
imageUrl: coverPreviewUrl,
|
||||
httpHeaders: {
|
||||
"Authorization": AuthUtil.fromAccount(account).toHeaderValue(),
|
||||
},
|
||||
filterQuality: FilterQuality.high,
|
||||
errorWidget: (context, url, error) {
|
||||
// just leave it empty
|
||||
return Container();
|
||||
},
|
||||
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
|
@ -1,262 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:kiwi/kiwi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/api/api_util.dart' as api_util;
|
||||
import 'package:nc_photos/app_localizations.dart';
|
||||
import 'package:nc_photos/debug_util.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/album.dart';
|
||||
import 'package:nc_photos/entity/album/cover_provider.dart';
|
||||
import 'package:nc_photos/entity/album/data_source.dart';
|
||||
import 'package:nc_photos/k.dart' as k;
|
||||
import 'package:nc_photos/notified_action.dart';
|
||||
import 'package:nc_photos/pref.dart';
|
||||
import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util;
|
||||
import 'package:nc_photos/use_case/import_pending_shared_album.dart';
|
||||
import 'package:nc_photos/use_case/update_album.dart';
|
||||
import 'package:nc_photos/widget/album_browser_app_bar.dart';
|
||||
import 'package:nc_photos/widget/album_browser_util.dart' as album_browser_util;
|
||||
import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util;
|
||||
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/zoom_menu_button.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
|
||||
part 'album_browser_mixin.g.dart';
|
||||
|
||||
@npLog
|
||||
mixin AlbumBrowserMixin<T extends StatefulWidget>
|
||||
on SelectableItemStreamListMixin<T> {
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
_thumbZoomLevel = Pref().getAlbumBrowserZoomLevelOr(0);
|
||||
}
|
||||
|
||||
@protected
|
||||
void initCover(Account account, Album album) {
|
||||
try {
|
||||
final coverFile = album.coverProvider.getCover(album);
|
||||
_coverPreviewUrl = api_util.getFilePreviewUrl(account, coverFile!,
|
||||
width: k.coverSize, height: k.coverSize, isKeepAspectRatio: false);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@protected
|
||||
Widget buildNormalAppBar(
|
||||
BuildContext context,
|
||||
Account account,
|
||||
Album album, {
|
||||
List<Widget>? actions,
|
||||
List<PopupMenuEntry<int>> Function(BuildContext)? menuItemBuilder,
|
||||
void Function(int)? onSelectedMenuItem,
|
||||
}) {
|
||||
final menuItems = [
|
||||
if (canEdit)
|
||||
PopupMenuItem(
|
||||
value: _menuValueEdit,
|
||||
child: Text(L10n.global().editAlbumMenuLabel),
|
||||
),
|
||||
if (canEdit && album.coverProvider is AlbumManualCoverProvider)
|
||||
PopupMenuItem(
|
||||
value: _menuValueUnsetCover,
|
||||
child: Text(L10n.global().unsetAlbumCoverTooltip),
|
||||
),
|
||||
];
|
||||
return AlbumBrowserAppBar(
|
||||
account: account,
|
||||
album: album,
|
||||
coverPreviewUrl: _coverPreviewUrl,
|
||||
actions: [
|
||||
ZoomMenuButton(
|
||||
initialZoom: _thumbZoomLevel,
|
||||
minZoom: 0,
|
||||
maxZoom: 2,
|
||||
onZoomChanged: (value) {
|
||||
setState(() {
|
||||
_thumbZoomLevel = value.round();
|
||||
});
|
||||
Pref().setAlbumBrowserZoomLevel(_thumbZoomLevel);
|
||||
},
|
||||
),
|
||||
if (album.albumFile?.path.startsWith(
|
||||
remote_storage_util.getRemotePendingSharedAlbumsDir(account)) ==
|
||||
true)
|
||||
IconButton(
|
||||
onPressed: () => _onAddToCollectionPressed(context, account, album),
|
||||
icon: const Icon(Icons.library_add),
|
||||
tooltip: L10n.global().addToCollectionsViewTooltip,
|
||||
),
|
||||
...(actions ?? []),
|
||||
if (menuItemBuilder != null || menuItems.isNotEmpty)
|
||||
PopupMenuButton<int>(
|
||||
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
|
||||
itemBuilder: (context) => [
|
||||
...menuItems,
|
||||
...(menuItemBuilder?.call(context) ?? []),
|
||||
],
|
||||
onSelected: (option) => _onMenuOptionSelected(
|
||||
option, account, album, onSelectedMenuItem),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
Widget buildSelectionAppBar(BuildContext context, List<Widget> actions) {
|
||||
return SelectionAppBar(
|
||||
count: selectedListItems.length,
|
||||
onClosePressed: () {
|
||||
setState(() {
|
||||
clearSelectedItems();
|
||||
});
|
||||
},
|
||||
actions: actions,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
Widget buildEditAppBar(
|
||||
BuildContext context,
|
||||
Account account,
|
||||
Album album, {
|
||||
List<Widget>? actions,
|
||||
}) {
|
||||
return AlbumBrowserEditAppBar(
|
||||
account: account,
|
||||
album: album,
|
||||
coverPreviewUrl: _coverPreviewUrl,
|
||||
actions: actions,
|
||||
onDonePressed: () {
|
||||
if (validateEditMode()) {
|
||||
setState(() {
|
||||
_isEditMode = false;
|
||||
});
|
||||
doneEditMode();
|
||||
}
|
||||
},
|
||||
onAlbumNameSaved: (value) {
|
||||
_editFormValue.name = value;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
bool get isEditMode => _isEditMode;
|
||||
|
||||
@protected
|
||||
bool get canEdit => true;
|
||||
|
||||
@protected
|
||||
@mustCallSuper
|
||||
void enterEditMode() {}
|
||||
|
||||
/// Validates the pending modifications
|
||||
@protected
|
||||
bool validateEditMode() => true;
|
||||
|
||||
@protected
|
||||
void doneEditMode() {}
|
||||
|
||||
/// Return a new album with the edits
|
||||
@protected
|
||||
Album makeEdited(Album album) {
|
||||
return album.copyWith(
|
||||
name: _editFormValue.name,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
int get thumbSize => photo_list_util.getThumbSize(_thumbZoomLevel);
|
||||
|
||||
void _onMenuOptionSelected(int option, Account account, Album album,
|
||||
void Function(int)? onSelectedMenuItem) {
|
||||
if (option >= 0) {
|
||||
onSelectedMenuItem?.call(option);
|
||||
} else {
|
||||
switch (option) {
|
||||
case _menuValueEdit:
|
||||
_onAppBarEditPressed(album);
|
||||
break;
|
||||
|
||||
case _menuValueUnsetCover:
|
||||
_onUnsetCoverPressed(account, album);
|
||||
break;
|
||||
|
||||
default:
|
||||
_log.shout("[_onMenuOptionSelected] Unknown value: $option");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onAppBarEditPressed(Album album) {
|
||||
setState(() {
|
||||
_isEditMode = true;
|
||||
enterEditMode();
|
||||
_editFormValue = _EditFormValue();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onUnsetCoverPressed(Account account, Album album) async {
|
||||
_log.info("[_onUnsetCoverPressed] Unset album cover for '${album.name}'");
|
||||
final c = KiwiContainer().resolve<DiContainer>();
|
||||
try {
|
||||
await NotifiedAction(
|
||||
() async {
|
||||
final albumRepo = AlbumRepo(AlbumCachedDataSource(c));
|
||||
await UpdateAlbum(albumRepo)(
|
||||
account,
|
||||
album.copyWith(
|
||||
coverProvider: const AlbumAutoCoverProvider(),
|
||||
));
|
||||
},
|
||||
L10n.global().unsetAlbumCoverProcessingNotification,
|
||||
L10n.global().unsetAlbumCoverSuccessNotification,
|
||||
failureText: L10n.global().setCollectionCoverFailureNotification,
|
||||
)();
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout(
|
||||
"[_onUnsetCoverPressed] Failed while updating album", e, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onAddToCollectionPressed(
|
||||
BuildContext context, Account account, Album album) async {
|
||||
Album? newAlbum;
|
||||
try {
|
||||
await NotifiedAction(
|
||||
() async {
|
||||
newAlbum = await ImportPendingSharedAlbum(
|
||||
KiwiContainer().resolve<DiContainer>())(account, album);
|
||||
},
|
||||
L10n.global().addToCollectionsViewProcessingNotification(album.name),
|
||||
L10n.global().addToCollectionsViewSuccessNotification(album.name),
|
||||
)();
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout(
|
||||
"[_onAddToCollectionPressed] Failed while _onAddToCollectionPressed: ${logFilename(album.albumFile?.path)}",
|
||||
e,
|
||||
stackTrace);
|
||||
}
|
||||
if (newAlbum != null && mounted) {
|
||||
album_browser_util.pushReplacement(context, account, newAlbum!);
|
||||
}
|
||||
}
|
||||
|
||||
String? _coverPreviewUrl;
|
||||
var _thumbZoomLevel = 0;
|
||||
|
||||
var _isEditMode = false;
|
||||
var _editFormValue = _EditFormValue();
|
||||
|
||||
static const _menuValueEdit = -1;
|
||||
static const _menuValueUnsetCover = -2;
|
||||
}
|
||||
|
||||
class _EditFormValue {
|
||||
late String name;
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'album_browser_mixin.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// NpLogGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$AlbumBrowserMixinNpLog on AlbumBrowserMixin {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("widget.album_browser_mixin.AlbumBrowserMixin");
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/entity/album.dart';
|
||||
import 'package:nc_photos/entity/album/provider.dart';
|
||||
import 'package:nc_photos/widget/album_browser.dart';
|
||||
|
||||
/// Push the corresponding browser route for this album
|
||||
void push(BuildContext context, Account account, Album album) {
|
||||
if (album.provider is AlbumStaticProvider) {
|
||||
Navigator.of(context).pushNamed(AlbumBrowser.routeName,
|
||||
arguments: AlbumBrowserArguments(account, album));
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the corresponding browser route for this album and replace the current
|
||||
/// route
|
||||
void pushReplacement(BuildContext context, Account account, Album album) {
|
||||
if (album.provider is AlbumStaticProvider) {
|
||||
Navigator.of(context).pushReplacementNamed(AlbumBrowser.routeName,
|
||||
arguments: AlbumBrowserArguments(account, album));
|
||||
}
|
||||
}
|
|
@ -1,59 +0,0 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import 'package:nc_photos/widget/draggable.dart' as my;
|
||||
import 'package:nc_photos/widget/measurable_item_list.dart';
|
||||
|
||||
abstract class DraggableItem {
|
||||
Widget buildWidget(BuildContext context);
|
||||
|
||||
/// The widget to show under the pointer when a drag is under way.
|
||||
///
|
||||
/// Return null if you wish to just use the same widget as display
|
||||
Widget? buildDragFeedbackWidget(BuildContext context) => null;
|
||||
|
||||
bool get isDraggable => false;
|
||||
DragTargetAccept<DraggableItem>? get onDropBefore => null;
|
||||
DragTargetAccept<DraggableItem>? get onDropAfter => null;
|
||||
VoidCallback? get onDragStarted => null;
|
||||
VoidCallback? get onDragEndedAny => null;
|
||||
StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1);
|
||||
}
|
||||
|
||||
mixin DraggableItemListMixin<T extends StatefulWidget> on State<T> {
|
||||
@protected
|
||||
Widget buildDraggableItemList({
|
||||
required double maxCrossAxisExtent,
|
||||
ValueChanged<double?>? onMaxExtentChanged,
|
||||
}) {
|
||||
_maxCrossAxisExtent = maxCrossAxisExtent;
|
||||
return MeasurableItemList(
|
||||
maxCrossAxisExtent: maxCrossAxisExtent,
|
||||
itemCount: _items.length,
|
||||
itemBuilder: _buildItem,
|
||||
staggeredTileBuilder: (index) => _items[index].staggeredTile,
|
||||
onMaxExtentChanged: onMaxExtentChanged,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
set draggableItemList(List<DraggableItem> newItems) {
|
||||
_items = newItems;
|
||||
}
|
||||
|
||||
Widget _buildItem(BuildContext context, int index) {
|
||||
final item = _items[index];
|
||||
return my.Draggable(
|
||||
data: item,
|
||||
feedback: item.buildDragFeedbackWidget(context),
|
||||
onDropBefore: item.onDropBefore,
|
||||
onDropAfter: item.onDropAfter,
|
||||
onDragStarted: item.onDragStarted,
|
||||
onDragEndedAny: item.onDragEndedAny,
|
||||
feedbackSize: Size(_maxCrossAxisExtent * .65, _maxCrossAxisExtent * .65),
|
||||
child: item.buildWidget(context),
|
||||
);
|
||||
}
|
||||
|
||||
var _items = <DraggableItem>[];
|
||||
late double _maxCrossAxisExtent;
|
||||
}
|
|
@ -1,346 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:kiwi/kiwi.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mutex/mutex.dart';
|
||||
import 'package:nc_photos/account.dart';
|
||||
import 'package:nc_photos/app_localizations.dart';
|
||||
import 'package:nc_photos/async_util.dart' as async_util;
|
||||
import 'package:nc_photos/bloc/list_sharee.dart';
|
||||
import 'package:nc_photos/bloc/search_suggestion.dart';
|
||||
import 'package:nc_photos/di_container.dart';
|
||||
import 'package:nc_photos/entity/album.dart';
|
||||
import 'package:nc_photos/entity/sharee.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/use_case/album/share_album_with_user.dart';
|
||||
import 'package:nc_photos/use_case/album/unshare_album_with_user.dart';
|
||||
import 'package:nc_photos/widget/album_share_outlier_browser.dart';
|
||||
import 'package:nc_photos/widget/dialog_scaffold.dart';
|
||||
import 'package:np_codegen/np_codegen.dart';
|
||||
import 'package:np_common/ci_string.dart';
|
||||
|
||||
part 'share_album_dialog.g.dart';
|
||||
|
||||
class ShareAlbumDialog extends StatefulWidget {
|
||||
ShareAlbumDialog({
|
||||
Key? key,
|
||||
required this.account,
|
||||
required this.album,
|
||||
}) : assert(album.albumFile != null),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
createState() => _ShareAlbumDialogState();
|
||||
|
||||
final Account account;
|
||||
final Album album;
|
||||
}
|
||||
|
||||
@npLog
|
||||
class _ShareAlbumDialogState extends State<ShareAlbumDialog> {
|
||||
_ShareAlbumDialogState() {
|
||||
final c = KiwiContainer().resolve<DiContainer>();
|
||||
assert(require(c));
|
||||
_c = c;
|
||||
}
|
||||
|
||||
static bool require(DiContainer c) =>
|
||||
DiContainer.has(c, DiType.albumRepo) &&
|
||||
DiContainer.has(c, DiType.shareRepo);
|
||||
|
||||
@override
|
||||
initState() {
|
||||
super.initState();
|
||||
_album = widget.album;
|
||||
_items = _album.shares
|
||||
?.map((s) =>
|
||||
_ShareItem(s.userId, s.displayName ?? s.userId.toString()))
|
||||
.toList() ??
|
||||
[];
|
||||
_initBloc();
|
||||
}
|
||||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
return DialogScaffold(
|
||||
canPop: _processingSharee.isEmpty,
|
||||
body: BlocListener<ListShareeBloc, ListShareeBlocState>(
|
||||
bloc: _shareeBloc,
|
||||
listener: _onShareeStateChange,
|
||||
child: Builder(
|
||||
builder: _buildContent,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
return SimpleDialog(
|
||||
title: Text(L10n.global().shareAlbumDialogTitle),
|
||||
children: [
|
||||
..._items.map((i) => _buildItem(context, i)),
|
||||
_buildCreateShareItem(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItem(BuildContext context, _ShareItem share) {
|
||||
final isProcessing = _processingSharee.any((s) => s == share.shareWith);
|
||||
final Widget trailing;
|
||||
if (isProcessing) {
|
||||
trailing = const Padding(
|
||||
padding: EdgeInsetsDirectional.only(end: 12),
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
trailing = Checkbox(
|
||||
value: true,
|
||||
onChanged: (_) {},
|
||||
);
|
||||
}
|
||||
return SimpleDialogOption(
|
||||
onPressed: isProcessing ? () {} : () => _onShareItemPressed(share),
|
||||
child: ListTile(
|
||||
title: Text(share.displayName),
|
||||
subtitle: Text(share.shareWith.toString()),
|
||||
// pass through the tap event
|
||||
trailing: IgnorePointer(
|
||||
child: trailing,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCreateShareItem(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: TypeAheadField<Sharee>(
|
||||
textFieldConfiguration: TextFieldConfiguration(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: L10n.global().addUserInputHint,
|
||||
),
|
||||
),
|
||||
suggestionsCallback: _onSearch,
|
||||
itemBuilder: (context, suggestion) => ListTile(
|
||||
title: Text(suggestion.label),
|
||||
subtitle: Text(suggestion.shareWith.toString()),
|
||||
),
|
||||
onSuggestionSelected: _onSearchSuggestionSelected,
|
||||
hideOnEmpty: true,
|
||||
hideOnLoading: true,
|
||||
autoFlipDirection: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onShareeStateChange(BuildContext context, ListShareeBlocState state) {
|
||||
if (state is ListShareeBlocSuccess) {
|
||||
_transformShareeItems(state.items);
|
||||
} else if (state is ListShareeBlocFailure) {
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(exception_util.toUserString(state.exception)),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onShareItemPressed(_ShareItem share) async {
|
||||
setState(() {
|
||||
_processingSharee.add(share.shareWith);
|
||||
});
|
||||
try {
|
||||
if (await _removeShare(share)) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_items.remove(share);
|
||||
_onShareItemListUpdated();
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_processingSharee.remove(share.shareWith);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Iterable<Sharee>> _onSearch(String pattern) async {
|
||||
_suggestionBloc.add(SearchSuggestionBlocSearchEvent(pattern.toCi()));
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
await async_util
|
||||
.wait(() => _suggestionBloc.state is! SearchSuggestionBlocLoading);
|
||||
if (_suggestionBloc.state is SearchSuggestionBlocSuccess) {
|
||||
return _suggestionBloc.state.results;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSearchSuggestionSelected(Sharee sharee) async {
|
||||
_searchController.clear();
|
||||
final item = _ShareItem(sharee.shareWith, sharee.label);
|
||||
var isGood = false;
|
||||
setState(() {
|
||||
_items.add(item);
|
||||
_onShareItemListUpdated();
|
||||
_processingSharee.add(sharee.shareWith);
|
||||
});
|
||||
try {
|
||||
isGood = await _createShare(sharee);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (!isGood) {
|
||||
_items.remove(item);
|
||||
_onShareItemListUpdated();
|
||||
}
|
||||
_processingSharee.remove(sharee.shareWith);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onShareItemListUpdated() {
|
||||
if (_shareeBloc.state is ListShareeBlocSuccess) {
|
||||
_transformShareeItems(_shareeBloc.state.items);
|
||||
}
|
||||
}
|
||||
|
||||
void _onFixPressed() {
|
||||
Navigator.of(context).pushNamed(AlbumShareOutlierBrowser.routeName,
|
||||
arguments: AlbumShareOutlierBrowserArguments(widget.account, _album));
|
||||
}
|
||||
|
||||
void _transformShareeItems(List<Sharee> sharees) {
|
||||
final candidates = sharees
|
||||
.where((s) =>
|
||||
s.shareWith != widget.account.userId &&
|
||||
// remove users already shared with
|
||||
!_items.any((i) => i.shareWith == s.shareWith))
|
||||
.toList();
|
||||
_suggestionBloc
|
||||
.add(SearchSuggestionBlocUpdateItemsEvent<Sharee>(candidates));
|
||||
}
|
||||
|
||||
Future<bool> _createShare(Sharee sharee) async {
|
||||
var hasFailure = false;
|
||||
try {
|
||||
_album = await _editMutex.protect(() async {
|
||||
return await ShareAlbumWithUser(_c.shareRepo, _c.albumRepo)(
|
||||
widget.account,
|
||||
_album,
|
||||
sharee,
|
||||
onShareFileFailed: (_, __, ___) {
|
||||
hasFailure = true;
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout(
|
||||
"[_createShare] Failed while ShareAlbumWithUser", e, stackTrace);
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(exception_util.toUserString(e)),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
return false;
|
||||
}
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(hasFailure
|
||||
? L10n.global()
|
||||
.shareAlbumSuccessWithErrorNotification(sharee.shareWith)
|
||||
: L10n.global().shareAlbumSuccessNotification(sharee.shareWith)),
|
||||
action: hasFailure
|
||||
? SnackBarAction(
|
||||
label: L10n.global().fixButtonLabel,
|
||||
onPressed: _onFixPressed,
|
||||
)
|
||||
: null,
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _removeShare(_ShareItem share) async {
|
||||
var hasFailure = false;
|
||||
try {
|
||||
_album = await _editMutex.protect(() async {
|
||||
return await UnshareAlbumWithUser(
|
||||
KiwiContainer().resolve<DiContainer>())(
|
||||
widget.account,
|
||||
_album,
|
||||
share.shareWith,
|
||||
onUnshareFileFailed: (_, __, ___) {
|
||||
hasFailure = true;
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (e, stackTrace) {
|
||||
_log.shout(
|
||||
"[_removeShare] Failed while UnshareAlbumWithUser", e, stackTrace);
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(exception_util.toUserString(e)),
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
return false;
|
||||
}
|
||||
SnackBarManager().showSnackBar(SnackBar(
|
||||
content: Text(hasFailure
|
||||
? L10n.global()
|
||||
.unshareAlbumSuccessWithErrorNotification(share.shareWith)
|
||||
: L10n.global().unshareAlbumSuccessNotification(share.shareWith)),
|
||||
action: hasFailure
|
||||
? SnackBarAction(
|
||||
label: L10n.global().fixButtonLabel,
|
||||
onPressed: _onFixPressed,
|
||||
)
|
||||
: null,
|
||||
duration: k.snackBarDurationNormal,
|
||||
));
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _initBloc() async {
|
||||
if (_shareeBloc.state is ListShareeBlocSuccess) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_onShareeStateChange(context, _shareeBloc.state);
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_log.info("[_initBloc] Initialize bloc");
|
||||
_shareeBloc.add(ListShareeBlocQuery(widget.account));
|
||||
}
|
||||
}
|
||||
|
||||
late final DiContainer _c;
|
||||
|
||||
late final _shareeBloc = ListShareeBloc.of(widget.account);
|
||||
final _suggestionBloc = SearchSuggestionBloc<Sharee>(
|
||||
itemToKeywords: (item) => [item.shareWith, item.label.toCi()],
|
||||
);
|
||||
|
||||
late Album _album;
|
||||
final _editMutex = Mutex();
|
||||
late final List<_ShareItem> _items;
|
||||
final _processingSharee = <CiString>[];
|
||||
final _searchController = TextEditingController();
|
||||
}
|
||||
|
||||
class _ShareItem {
|
||||
_ShareItem(this.shareWith, this.displayName);
|
||||
|
||||
final CiString shareWith;
|
||||
final String displayName;
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'share_album_dialog.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// NpLogGenerator
|
||||
// **************************************************************************
|
||||
|
||||
extension _$_ShareAlbumDialogStateNpLog on _ShareAlbumDialogState {
|
||||
// ignore: unused_element
|
||||
Logger get _log => log;
|
||||
|
||||
static final log = Logger("widget.share_album_dialog._ShareAlbumDialogState");
|
||||
}
|
Loading…
Reference in a new issue