Merge branch 'exif-service' into dev

This commit is contained in:
Ming Ming 2022-04-07 01:23:42 +08:00
commit 55ed608144
13 changed files with 676 additions and 129 deletions

View file

@ -12,7 +12,9 @@ import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/event/event.dart'; import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/event/native_event.dart';
import 'package:nc_photos/exception_event.dart'; import 'package:nc_photos/exception_event.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/pref.dart'; import 'package:nc_photos/pref.dart';
import 'package:nc_photos/throttler.dart'; import 'package:nc_photos/throttler.dart';
import 'package:nc_photos/touch_token_manager.dart'; import 'package:nc_photos/touch_token_manager.dart';
@ -112,6 +114,8 @@ class ScanAccountDirBloc
_favoriteResyncedEventListener.begin(); _favoriteResyncedEventListener.begin();
_prefUpdatedEventListener.begin(); _prefUpdatedEventListener.begin();
_accountPrefUpdatedEventListener.begin(); _accountPrefUpdatedEventListener.begin();
_nativeFileExifUpdatedListener?.begin();
} }
static ScanAccountDirBloc of(Account account) { static ScanAccountDirBloc of(Account account) {
@ -164,6 +168,8 @@ class ScanAccountDirBloc
_prefUpdatedEventListener.end(); _prefUpdatedEventListener.end();
_accountPrefUpdatedEventListener.end(); _accountPrefUpdatedEventListener.end();
_nativeFileExifUpdatedListener?.end();
_refreshThrottler.clear(); _refreshThrottler.clear();
return super.close(); return super.close();
} }
@ -304,6 +310,13 @@ class ScanAccountDirBloc
} }
} }
void _onNativeFileExifUpdated(FileExifUpdatedEvent ev) {
_refreshThrottler.trigger(
maxResponceTime: const Duration(seconds: 3),
maxPendingCount: 10,
);
}
Future<List<File>> _queryOffline(ScanAccountDirBlocQueryBase ev) async { Future<List<File>> _queryOffline(ScanAccountDirBlocQueryBase ev) async {
final c = KiwiContainer().resolve<DiContainer>(); final c = KiwiContainer().resolve<DiContainer>();
final files = <File>[]; final files = <File>[];
@ -484,6 +497,10 @@ class ScanAccountDirBloc
late final _accountPrefUpdatedEventListener = late final _accountPrefUpdatedEventListener =
AppEventListener<AccountPrefUpdatedEvent>(_onAccountPrefUpdatedEvent); AppEventListener<AccountPrefUpdatedEvent>(_onAccountPrefUpdatedEvent);
late final _nativeFileExifUpdatedListener = platform_k.isWeb
? null
: NativeEventListener<FileExifUpdatedEvent>(_onNativeFileExifUpdated);
late final _refreshThrottler = Throttler( late final _refreshThrottler = Throttler(
onTriggered: (_) { onTriggered: (_) {
add(const _ScanAccountDirBlocExternalEvent()); add(const _ScanAccountDirBlocExternalEvent());

View file

@ -0,0 +1,67 @@
import 'dart:async';
import 'dart:convert';
import 'package:logging/logging.dart';
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
class NativeEventListener<T> {
NativeEventListener(this.listener);
void begin() {
if (_subscription != null) {
_log.warning("[begin] Already listening");
return;
}
_subscription = _mappedStream
.where((event) => event is T)
.cast<T>()
.listen(listener);
}
void end() {
if (_subscription == null) {
_log.warning("[end] Already not listening");
return;
}
_subscription?.cancel();
_subscription = null;
}
static late final _mappedStream =
NativeEvent.stream.where((event) => event is NativeEventObject).map((ev) {
switch (ev.event) {
case FileExifUpdatedEvent._id:
return FileExifUpdatedEvent.fromEvent(ev);
default:
throw ArgumentError("Invalid event: ${ev.event}");
}
});
final void Function(T) listener;
StreamSubscription<T>? _subscription;
final _log = Logger("native_event.NativeEventListener<${T.runtimeType}>");
}
class FileExifUpdatedEvent {
const FileExifUpdatedEvent(this.fileIds);
factory FileExifUpdatedEvent.fromEvent(NativeEventObject ev) {
assert(ev.event == _id);
assert(ev.data != null);
final dataJson = jsonDecode(ev.data!) as Map;
return FileExifUpdatedEvent((dataJson["fileIds"] as List).cast<int>());
}
NativeEventObject toEvent() => NativeEventObject(
_id,
jsonEncode({
"fileIds": fileIds,
}),
);
static const _id = "FileExifUpdatedEvent";
final List<int> fileIds;
}

View file

@ -1167,6 +1167,10 @@
"@tagPickerNoTagSelectedNotification": { "@tagPickerNoTagSelectedNotification": {
"description": "At least 1 tag is required to create a tag collection. This error is shown when user try to create one without selecting any tags" "description": "At least 1 tag is required to create a tag collection. This error is shown when user try to create one without selecting any tags"
}, },
"backgroundServiceStopping": "Stopping service",
"@backgroundServiceStopping": {
"description": "The background service is stopping itself"
},
"errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues",
"@errorUnauthenticated": { "@errorUnauthenticated": {

View file

@ -82,6 +82,7 @@
"createCollectionDialogTagDescription", "createCollectionDialogTagDescription",
"addTagInputHint", "addTagInputHint",
"tagPickerNoTagSelectedNotification", "tagPickerNoTagSelectedNotification",
"backgroundServiceStopping",
"errorAlbumDowngrade" "errorAlbumDowngrade"
], ],
@ -182,6 +183,7 @@
"createCollectionDialogTagDescription", "createCollectionDialogTagDescription",
"addTagInputHint", "addTagInputHint",
"tagPickerNoTagSelectedNotification", "tagPickerNoTagSelectedNotification",
"backgroundServiceStopping",
"errorAlbumDowngrade" "errorAlbumDowngrade"
], ],
@ -337,12 +339,18 @@
"createCollectionDialogTagDescription", "createCollectionDialogTagDescription",
"addTagInputHint", "addTagInputHint",
"tagPickerNoTagSelectedNotification", "tagPickerNoTagSelectedNotification",
"backgroundServiceStopping",
"errorAlbumDowngrade" "errorAlbumDowngrade"
], ],
"es": [ "es": [
"rootPickerSkipConfirmationDialogContent2", "rootPickerSkipConfirmationDialogContent2",
"helpButtonLabel" "helpButtonLabel",
"backgroundServiceStopping"
],
"fi": [
"backgroundServiceStopping"
], ],
"fr": [ "fr": [
@ -477,6 +485,7 @@
"createCollectionDialogTagDescription", "createCollectionDialogTagDescription",
"addTagInputHint", "addTagInputHint",
"tagPickerNoTagSelectedNotification", "tagPickerNoTagSelectedNotification",
"backgroundServiceStopping",
"errorAlbumDowngrade" "errorAlbumDowngrade"
], ],
@ -499,7 +508,8 @@
"createCollectionDialogTagLabel", "createCollectionDialogTagLabel",
"createCollectionDialogTagDescription", "createCollectionDialogTagDescription",
"addTagInputHint", "addTagInputHint",
"tagPickerNoTagSelectedNotification" "tagPickerNoTagSelectedNotification",
"backgroundServiceStopping"
], ],
"ru": [ "ru": [
@ -607,6 +617,7 @@
"createCollectionDialogTagDescription", "createCollectionDialogTagDescription",
"addTagInputHint", "addTagInputHint",
"tagPickerNoTagSelectedNotification", "tagPickerNoTagSelectedNotification",
"backgroundServiceStopping",
"errorAlbumDowngrade" "errorAlbumDowngrade"
] ]
} }

266
app/lib/service.dart Normal file
View file

@ -0,0 +1,266 @@
import 'package:devicelocale/devicelocale.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations_en.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/app_init.dart' as app_init;
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/event/native_event.dart';
import 'package:nc_photos/language_util.dart' as language_util;
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/use_case/update_missing_metadata.dart';
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
/// Start the background service
Future<void> startService() async {
_log.info("[startService] Starting service");
final service = FlutterBackgroundService();
await service.configure(
androidConfiguration: AndroidConfiguration(
onStart: serviceMain,
autoStart: false,
isForegroundMode: true,
foregroundServiceNotificationTitle:
L10n.global().metadataTaskProcessingNotification,
),
iosConfiguration: IosConfiguration(
onForeground: () => throw UnimplementedError(),
onBackground: () => throw UnimplementedError(),
),
);
await service.start();
}
/// Ask the background service to stop ASAP
void stopService() {
_log.info("[stopService] Stopping service");
FlutterBackgroundService().sendData({
"stop": true,
});
}
@visibleForTesting
void serviceMain() async {
_Service._shouldRun = true;
WidgetsFlutterBinding.ensureInitialized();
await _Service()();
}
class _Service {
Future<void> call() async {
await app_init.initAppLaunch();
await _L10n().init();
_log.info("[call] Service started");
final service = FlutterBackgroundService();
service.setForegroundMode(true);
final onCancelSubscription = service.onCancel.listen((_) {
_log.info("[call] User canceled");
_stopSelf();
});
final onDataSubscription =
service.onDataReceived.listen((event) => _onReceiveData(event ?? {}));
try {
await _doWork();
} catch (e, stackTrace) {
_log.shout("[call] Uncaught exception", e, stackTrace);
}
onCancelSubscription.cancel();
onDataSubscription.cancel();
service.stopBackgroundService();
_log.info("[call] Service stopped");
}
Future<void> _doWork() async {
final account = Pref().getCurrentAccount();
if (account == null) {
_log.shout("[_doWork] account == null");
return;
}
final accountPref = AccountPref.of(account);
final service = FlutterBackgroundService();
final metadataTask = _MetadataTask(service, account, accountPref);
_metadataTaskStateChangedListener.begin();
try {
await metadataTask();
} finally {
_metadataTaskStateChangedListener.end();
}
}
void _onReceiveData(Map<String, dynamic> data) {
try {
for (final e in data.entries) {
switch (e.key) {
case "stop":
_stopSelf();
break;
}
}
} catch (e, stackTrace) {
_log.shout("[_onReceiveData] Uncaught exception", e, stackTrace);
}
}
void _onMetadataTaskStateChanged(MetadataTaskStateChangedEvent ev) {
if (ev.state == _metadataTaskState) {
return;
}
_metadataTaskState = ev.state;
if (ev.state == MetadataTaskState.waitingForWifi) {
final service = FlutterBackgroundService();
service.setNotificationInfo(
title: _L10n.global().metadataTaskPauseNoWiFiNotification,
);
}
}
void _stopSelf() {
_log.info("[_stopSelf] Stopping service");
FlutterBackgroundService().setNotificationInfo(
title: _L10n.global().backgroundServiceStopping,
);
_shouldRun = false;
}
var _metadataTaskState = MetadataTaskState.idle;
late final _metadataTaskStateChangedListener =
AppEventListener<MetadataTaskStateChangedEvent>(
_onMetadataTaskStateChanged);
static var _shouldRun = true;
static final _log = Logger("service._Service");
}
/// Access localized string out of the main isolate
class _L10n {
factory _L10n() => _inst;
_L10n._();
Future<void> init() async {
try {
final locale = language_util.getSelectedLocale();
if (locale == null) {
_l10n = await _queryL10n();
} else {
_l10n = lookupAppLocalizations(locale);
}
} catch (e, stackTrace) {
_log.shout("[init] Uncaught exception", e, stackTrace);
_l10n = AppLocalizationsEn();
}
}
static AppLocalizations global() => _L10n()._l10n;
Future<AppLocalizations> _queryL10n() async {
try {
final locale = await Devicelocale.currentAsLocale;
return lookupAppLocalizations(locale!);
} on FlutterError catch (_) {
// unsupported locale, use default (en)
return AppLocalizationsEn();
} catch (e, stackTrace) {
_log.shout(
"[_queryL10n] Failed while lookupAppLocalizations", e, stackTrace);
return AppLocalizationsEn();
}
}
static late final _inst = _L10n._();
late AppLocalizations _l10n;
static final _log = Logger("service._L10n");
}
class _MetadataTask {
_MetadataTask(this.service, this.account, this.accountPref);
Future<void> call() async {
try {
await _updateMetadata();
} catch (e, stackTrace) {
_log.shout("[call] Uncaught exception", e, stackTrace);
}
if (_processedIds.isNotEmpty) {
NativeEvent.fire(FileExifUpdatedEvent(_processedIds).toEvent());
_processedIds = [];
}
}
Future<void> _updateMetadata() async {
final shareFolder = File(
path: file_util.unstripPath(account, accountPref.getShareFolderOr()));
bool hasScanShareFolder = false;
final fileRepo = FileRepo(FileCachedDataSource(AppDb()));
for (final r in account.roots) {
final dir = File(path: file_util.unstripPath(account, r));
hasScanShareFolder |= file_util.isOrUnderDir(shareFolder, dir);
final updater = UpdateMissingMetadata(fileRepo);
await for (final ev in updater(account, dir)) {
if (!_Service._shouldRun) {
_log.info("[_updateMetadata] Stopping task: user canceled");
updater.stop();
return;
}
if (ev is File) {
_onFileProcessed(ev);
}
}
}
if (!hasScanShareFolder) {
final shareUpdater = UpdateMissingMetadata(fileRepo);
await for (final ev in shareUpdater(
account,
shareFolder,
isRecursive: false,
filter: (f) => f.ownerId != account.username,
)) {
if (!_Service._shouldRun) {
_log.info("[_updateMetadata] Stopping task: user canceled");
shareUpdater.stop();
return;
}
if (ev is File) {
_onFileProcessed(ev);
}
}
}
}
void _onFileProcessed(File file) {
++_count;
service.setNotificationInfo(
title: _L10n.global().metadataTaskProcessingNotification,
content: file.strippedPath,
);
_processedIds.add(file.fileId!);
if (_processedIds.length >= 10) {
NativeEvent.fire(FileExifUpdatedEvent(_processedIds).toEvent());
_processedIds = [];
}
}
final FlutterBackgroundService service;
final Account account;
final AccountPref accountPref;
var _count = 0;
var _processedIds = <int>[];
static final _log = Logger("service._MetadataTask");
}
final _log = Logger("service");

View file

@ -86,6 +86,9 @@ class UpdateMissingMetadata {
metadata: OrNull(metadataObj), metadata: OrNull(metadataObj),
); );
yield file; yield file;
// slow down a bit to give some space for the main isolate
await Future.delayed(const Duration(milliseconds: 10));
} catch (e, stackTrace) { } catch (e, stackTrace) {
_log.severe("[call] Failed while updating metadata: ${file.path}", e, _log.severe("[call] Failed while updating metadata: ${file.path}", e,
stackTrace); stackTrace);

View file

@ -22,8 +22,10 @@ import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/metadata_task_manager.dart'; import 'package:nc_photos/metadata_task_manager.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/pref.dart'; import 'package:nc_photos/pref.dart';
import 'package:nc_photos/primitive.dart'; import 'package:nc_photos/primitive.dart';
import 'package:nc_photos/service.dart' as service;
import 'package:nc_photos/share_handler.dart'; import 'package:nc_photos/share_handler.dart';
import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart'; import 'package:nc_photos/theme.dart';
@ -66,17 +68,14 @@ class _HomePhotosState extends State<HomePhotos>
super.initState(); super.initState();
_thumbZoomLevel = Pref().getHomePhotosZoomLevelOr(0); _thumbZoomLevel = Pref().getHomePhotosZoomLevelOr(0);
_initBloc(); _initBloc();
_metadataTaskStateChangedListener.begin(); _web?.onInitState();
_prefUpdatedListener.begin(); _prefUpdatedListener.begin();
_filePropertyUpdatedListener.begin();
} }
@override @override
dispose() { dispose() {
_metadataTaskIconController.stop();
_metadataTaskStateChangedListener.end();
_prefUpdatedListener.end(); _prefUpdatedListener.end();
_filePropertyUpdatedListener.end(); _web?.onDispose();
super.dispose(); super.dispose();
} }
@ -137,8 +136,7 @@ class _HomePhotosState extends State<HomePhotos>
controller: _scrollController, controller: _scrollController,
slivers: [ slivers: [
_buildAppBar(context), _buildAppBar(context),
if (_metadataTaskState != MetadataTaskState.idle) _web?.buildContent(context),
_buildMetadataTaskHeader(context),
if (AccountPref.of(widget.account) if (AccountPref.of(widget.account)
.isEnableMemoryAlbumOr(true) && .isEnableMemoryAlbumOr(true) &&
_smartAlbums.isNotEmpty) _smartAlbums.isNotEmpty)
@ -151,7 +149,7 @@ class _HomePhotosState extends State<HomePhotos>
}); });
}, },
), ),
], ].whereType<Widget>().toList(),
), ),
), ),
), ),
@ -260,81 +258,6 @@ class _HomePhotosState extends State<HomePhotos>
); );
} }
Widget _buildMetadataTaskHeader(BuildContext context) {
return SliverPersistentHeader(
pinned: true,
floating: false,
delegate: _MetadataTaskHeaderDelegate(
extent: _metadataTaskHeaderHeight,
builder: (context) => Container(
height: double.infinity,
color: Theme.of(context).scaffoldBackgroundColor,
alignment: AlignmentDirectional.centerStart,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
if (_metadataTaskState == MetadataTaskState.prcoessing)
Row(
mainAxisSize: MainAxisSize.min,
children: [
_MetadataTaskLoadingIcon(
controller: _metadataTaskIconController,
),
const SizedBox(width: 4),
Text(
L10n.global().metadataTaskProcessingNotification +
_getMetadataTaskProgressString(),
style: const TextStyle(fontSize: 12),
),
],
)
else if (_metadataTaskState == MetadataTaskState.waitingForWifi)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.sync_problem,
size: 16,
),
const SizedBox(width: 4),
Text(
L10n.global().metadataTaskPauseNoWiFiNotification,
style: const TextStyle(fontSize: 12),
),
],
),
Expanded(
child: Container(),
),
Material(
type: MaterialType.transparency,
child: InkWell(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
child: Text(
L10n.global().configButtonLabel,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 12,
),
),
),
onTap: () {
Navigator.of(context).pushNamed(Settings.routeName,
arguments: SettingsArguments(widget.account));
},
),
),
],
),
),
),
),
);
}
Widget _buildSmartAlbumList(BuildContext context) { Widget _buildSmartAlbumList(BuildContext context) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: SizedBox( child: SizedBox(
@ -487,48 +410,44 @@ class _HomePhotosState extends State<HomePhotos>
); );
} }
void _onMetadataTaskStateChanged(MetadataTaskStateChangedEvent ev) {
if (ev.state == MetadataTaskState.idle) {
_metadataTaskProcessCount = 0;
}
if (ev.state != _metadataTaskState) {
setState(() {
_metadataTaskState = ev.state;
});
}
}
void _onPrefUpdated(PrefUpdatedEvent ev) { void _onPrefUpdated(PrefUpdatedEvent ev) {
if (ev.key == PrefKey.enableExif && ev.value == true) { if (ev.key == PrefKey.enableExif) {
_tryStartMetadataTask(ignoreFired: true); if (ev.value == true) {
_tryStartMetadataTask(ignoreFired: true);
} else {
_stopMetadataTask();
}
} }
} }
void _onFilePropertyUpdated(FilePropertyUpdatedEvent ev) {
if (!ev.hasAnyProperties([FilePropertyUpdatedEvent.propMetadata])) {
return;
}
setState(() {
++_metadataTaskProcessCount;
});
}
void _tryStartMetadataTask({ void _tryStartMetadataTask({
bool ignoreFired = false, bool ignoreFired = false,
}) { }) {
if (_bloc.state is ScanAccountDirBlocSuccess && if (_bloc.state is ScanAccountDirBlocSuccess &&
Pref().isEnableExifOr() && Pref().isEnableExifOr() &&
(!_hasFiredMetadataTask.value || ignoreFired)) { (!_hasFiredMetadataTask.value || ignoreFired)) {
MetadataTaskManager().addTask( final missingMetadataCount = _backingFiles
MetadataTask(widget.account, AccountPref.of(widget.account)));
_metadataTaskProcessTotalCount = _backingFiles
.where( .where(
(f) => file_util.isSupportedImageFormat(f) && f.metadata == null) (f) => file_util.isSupportedImageFormat(f) && f.metadata == null)
.length; .length;
if (missingMetadataCount > 0) {
if (_web != null) {
_web!.startMetadataTask(missingMetadataCount);
} else {
service.startService();
}
}
_hasFiredMetadataTask.value = true; _hasFiredMetadataTask.value = true;
} }
} }
void _stopMetadataTask() {
if (_web == null) {
service.stopService();
}
}
Future<void> _syncFavorite() async { Future<void> _syncFavorite() async {
if (!_hasResyncedFavorites.value) { if (!_hasResyncedFavorites.value) {
final c = KiwiContainer().resolve<DiContainer>(); final c = KiwiContainer().resolve<DiContainer>();
@ -622,10 +541,7 @@ class _HomePhotosState extends State<HomePhotos>
if (_itemListMaxExtent != null && if (_itemListMaxExtent != null &&
constraints.hasBoundedHeight && constraints.hasBoundedHeight &&
_appBarExtent != null) { _appBarExtent != null) {
final metadataTaskHeaderExtent = final metadataTaskHeaderExtent = _web?.getHeaderHeight() ?? 0;
_metadataTaskState == MetadataTaskState.idle
? 0
: _metadataTaskHeaderHeight;
final smartAlbumListHeight = final smartAlbumListHeight =
AccountPref.of(widget.account).isEnableMemoryAlbumOr(true) && AccountPref.of(widget.account).isEnableMemoryAlbumOr(true) &&
_smartAlbums.isNotEmpty _smartAlbums.isNotEmpty
@ -647,15 +563,6 @@ class _HomePhotosState extends State<HomePhotos>
} }
} }
String _getMetadataTaskProgressString() {
if (_metadataTaskProcessTotalCount == 0) {
return "";
}
final clippedCount =
math.min(_metadataTaskProcessCount, _metadataTaskProcessTotalCount - 1);
return " ($clippedCount/$_metadataTaskProcessTotalCount)";
}
Primitive<bool> get _hasFiredMetadataTask { Primitive<bool> get _hasFiredMetadataTask {
final name = bloc_util.getInstNameForRootAwareAccount( final name = bloc_util.getInstNameForRootAwareAccount(
"HomePhotosState.hasFiredMetadataTask", widget.account); "HomePhotosState.hasFiredMetadataTask", widget.account);
@ -699,12 +606,161 @@ class _HomePhotosState extends State<HomePhotos>
double? _appBarExtent; double? _appBarExtent;
double? _itemListMaxExtent; double? _itemListMaxExtent;
late final _prefUpdatedListener =
AppEventListener<PrefUpdatedEvent>(_onPrefUpdated);
late final _Web? _web = platform_k.isWeb ? _Web(this) : null;
static final _log = Logger("widget.home_photos._HomePhotosState");
static const _menuValueRefresh = 0;
}
class _Web {
_Web(this.state);
void onInitState() {
_metadataTaskStateChangedListener.begin();
_filePropertyUpdatedListener.begin();
}
void onDispose() {
_metadataTaskIconController.stop();
_metadataTaskStateChangedListener.end();
_filePropertyUpdatedListener.end();
}
Widget? buildContent(BuildContext context) {
if (_metadataTaskState != MetadataTaskState.idle) {
return _buildMetadataTaskHeader(context);
} else {
return null;
}
}
void startMetadataTask(int missingMetadataCount) {
MetadataTaskManager().addTask(MetadataTask(
state.widget.account, AccountPref.of(state.widget.account)));
_metadataTaskProcessTotalCount = missingMetadataCount;
}
double getHeaderHeight() {
return _metadataTaskState == MetadataTaskState.idle
? 0
: _metadataTaskHeaderHeight;
}
Widget _buildMetadataTaskHeader(BuildContext context) {
return SliverPersistentHeader(
pinned: true,
floating: false,
delegate: _MetadataTaskHeaderDelegate(
extent: _metadataTaskHeaderHeight,
builder: (context) => Container(
height: double.infinity,
color: Theme.of(context).scaffoldBackgroundColor,
alignment: AlignmentDirectional.centerStart,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
if (_metadataTaskState == MetadataTaskState.prcoessing)
Row(
mainAxisSize: MainAxisSize.min,
children: [
_MetadataTaskLoadingIcon(
controller: _metadataTaskIconController,
),
const SizedBox(width: 4),
Text(
L10n.global().metadataTaskProcessingNotification +
_getMetadataTaskProgressString(),
style: const TextStyle(fontSize: 12),
),
],
)
else if (_metadataTaskState == MetadataTaskState.waitingForWifi)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.sync_problem,
size: 16,
),
const SizedBox(width: 4),
Text(
L10n.global().metadataTaskPauseNoWiFiNotification,
style: const TextStyle(fontSize: 12),
),
],
),
Expanded(
child: Container(),
),
Material(
type: MaterialType.transparency,
child: InkWell(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
child: Text(
L10n.global().configButtonLabel,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 12,
),
),
),
onTap: () {
Navigator.of(context).pushNamed(Settings.routeName,
arguments: SettingsArguments(state.widget.account));
},
),
),
],
),
),
),
),
);
}
void _onMetadataTaskStateChanged(MetadataTaskStateChangedEvent ev) {
if (ev.state == MetadataTaskState.idle) {
_metadataTaskProcessCount = 0;
}
if (ev.state != _metadataTaskState) {
// ignore: invalid_use_of_protected_member
state.setState(() {
_metadataTaskState = ev.state;
});
}
}
void _onFilePropertyUpdated(FilePropertyUpdatedEvent ev) {
if (!ev.hasAnyProperties([FilePropertyUpdatedEvent.propMetadata])) {
return;
}
// ignore: invalid_use_of_protected_member
state.setState(() {
++_metadataTaskProcessCount;
});
}
String _getMetadataTaskProgressString() {
if (_metadataTaskProcessTotalCount == 0) {
return "";
}
final clippedCount =
math.min(_metadataTaskProcessCount, _metadataTaskProcessTotalCount - 1);
return " ($clippedCount/$_metadataTaskProcessTotalCount)";
}
final _HomePhotosState state;
late final _metadataTaskStateChangedListener = late final _metadataTaskStateChangedListener =
AppEventListener<MetadataTaskStateChangedEvent>( AppEventListener<MetadataTaskStateChangedEvent>(
_onMetadataTaskStateChanged); _onMetadataTaskStateChanged);
var _metadataTaskState = MetadataTaskManager().state; var _metadataTaskState = MetadataTaskManager().state;
late final _prefUpdatedListener =
AppEventListener<PrefUpdatedEvent>(_onPrefUpdated);
late final _filePropertyUpdatedListener = late final _filePropertyUpdatedListener =
AppEventListener<FilePropertyUpdatedEvent>(_onFilePropertyUpdated); AppEventListener<FilePropertyUpdatedEvent>(_onFilePropertyUpdated);
var _metadataTaskProcessCount = 0; var _metadataTaskProcessCount = 0;
@ -712,12 +768,9 @@ class _HomePhotosState extends State<HomePhotos>
late final _metadataTaskIconController = AnimationController( late final _metadataTaskIconController = AnimationController(
upperBound: 2 * math.pi, upperBound: 2 * math.pi,
duration: const Duration(seconds: 10), duration: const Duration(seconds: 10),
vsync: this, vsync: state,
)..repeat(); )..repeat();
static final _log = Logger("widget.home_photos._HomePhotosState");
static const _menuValueRefresh = 0;
static const _metadataTaskHeaderHeight = 32.0; static const _metadataTaskHeaderHeight = 32.0;
} }

View file

@ -232,6 +232,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.1" version: "2.1.1"
devicelocale:
dependency: "direct main"
description:
name: devicelocale
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.0"
diff_match_patch: diff_match_patch:
dependency: transitive dependency: transitive
description: description:
@ -297,6 +304,15 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_background_service:
dependency: "direct main"
description:
path: "."
ref: "v0.2.6-nc-photos-1"
resolved-ref: c63c7ed595d39897db4791266e6208abd7d688e5
url: "https://gitlab.com/nc-photos/flutter_background_service.git"
source: git
version: "0.2.6"
flutter_bloc: flutter_bloc:
dependency: "direct main" dependency: "direct main"
description: description:

View file

@ -32,6 +32,7 @@ dependencies:
cached_network_image: ^3.0.0 cached_network_image: ^3.0.0
collection: ^1.15.0 collection: ^1.15.0
connectivity_plus: ^2.0.2 connectivity_plus: ^2.0.2
devicelocale: ^0.5.0
device_info_plus: ^3.1.0 device_info_plus: ^3.1.0
draggable_scrollbar: draggable_scrollbar:
git: git:
@ -43,6 +44,10 @@ dependencies:
git: git:
url: https://gitlab.com/nc-photos/exifdart.git url: https://gitlab.com/nc-photos/exifdart.git
ref: 1.1.0 ref: 1.1.0
flutter_background_service:
git:
url: https://gitlab.com/nc-photos/flutter_background_service.git
ref: v0.2.6-nc-photos-1
flutter_bloc: ^7.0.0 flutter_bloc: ^7.0.0
flutter_map: ^0.14.0 flutter_map: ^0.14.0
flutter_staggered_grid_view: flutter_staggered_grid_view:

View file

@ -0,0 +1,52 @@
package com.nkming.nc_photos.plugin
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class NativeEventChannelHandler : MethodChannel.MethodCallHandler,
EventChannel.StreamHandler {
companion object {
const val EVENT_CHANNEL = "${K.LIB_ID}/native_event"
const val METHOD_CHANNEL = "${K.LIB_ID}/native_event_method"
private val eventSinks = mutableMapOf<Int, EventChannel.EventSink>()
private var nextId = 0
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"fire" -> {
try {
fire(
call.argument("event")!!, call.argument("data"), result
)
} catch (e: Throwable) {
result.error("systemException", e.toString(), null)
}
}
}
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
eventSinks[id] = events
}
override fun onCancel(arguments: Any?) {
eventSinks.remove(id)
}
private fun fire(
event: String, data: String?, result: MethodChannel.Result
) {
for (s in eventSinks.values) {
s.success(buildMap {
put("event", event)
if (data != null) put("data", data)
})
}
result.success(null)
}
private val id = nextId++
}

View file

@ -2,12 +2,14 @@ package com.nkming.nc_photos.plugin
import androidx.annotation.NonNull import androidx.annotation.NonNull
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class NcPhotosPlugin : FlutterPlugin { class NcPhotosPlugin : FlutterPlugin {
override fun onAttachedToEngine( override fun onAttachedToEngine(
@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding @NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding
) { ) {
// this may get called more than once
lockChannel = MethodChannel( lockChannel = MethodChannel(
flutterPluginBinding.binaryMessenger, LockChannelHandler.CHANNEL flutterPluginBinding.binaryMessenger, LockChannelHandler.CHANNEL
) )
@ -22,6 +24,18 @@ class NcPhotosPlugin : FlutterPlugin {
flutterPluginBinding.applicationContext flutterPluginBinding.applicationContext
) )
) )
val nativeEventHandler = NativeEventChannelHandler()
nativeEventChannel = EventChannel(
flutterPluginBinding.binaryMessenger,
NativeEventChannelHandler.EVENT_CHANNEL
)
nativeEventChannel.setStreamHandler(nativeEventHandler)
nativeEventMethodChannel = MethodChannel(
flutterPluginBinding.binaryMessenger,
NativeEventChannelHandler.METHOD_CHANNEL
)
nativeEventMethodChannel.setMethodCallHandler(nativeEventHandler)
} }
override fun onDetachedFromEngine( override fun onDetachedFromEngine(
@ -29,8 +43,12 @@ class NcPhotosPlugin : FlutterPlugin {
) { ) {
lockChannel.setMethodCallHandler(null) lockChannel.setMethodCallHandler(null)
notificationChannel.setMethodCallHandler(null) notificationChannel.setMethodCallHandler(null)
nativeEventChannel.setStreamHandler(null)
nativeEventMethodChannel.setMethodCallHandler(null)
} }
private lateinit var lockChannel: MethodChannel private lateinit var lockChannel: MethodChannel
private lateinit var notificationChannel: MethodChannel private lateinit var notificationChannel: MethodChannel
private lateinit var nativeEventChannel: EventChannel
private lateinit var nativeEventMethodChannel: MethodChannel
} }

View file

@ -1,4 +1,5 @@
library nc_photos_plugin; library nc_photos_plugin;
export 'src/lock.dart'; export 'src/lock.dart';
export 'src/native_event.dart';
export 'src/notification.dart'; export 'src/notification.dart';

View file

@ -0,0 +1,34 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:nc_photos_plugin/src/k.dart' as k;
class NativeEventObject {
NativeEventObject(this.event, this.data);
final String event;
final String? data;
}
class NativeEvent {
static Future<void> fire(NativeEventObject ev) =>
_methodChannel.invokeMethod("fire", <String, dynamic>{
"event": ev.event,
if (ev.data != null) "data": ev.data,
});
static Stream get stream => _eventStream;
static const _eventChannel = EventChannel("${k.libId}/native_event");
static const _methodChannel = MethodChannel("${k.libId}/native_event_method");
static late final _eventStream = _eventChannel
.receiveBroadcastStream()
.map((event) {
if (event is Map) {
return NativeEventObject(event["event"], event["data"]);
} else {
return event;
}
});
}