feat: locked view mobile (#18316)
* feat: locked/private view * feat: locked/private view * feat: mobile lock/private view * feat: mobile lock/private view * merge main * pr feedback * pr feedback * bottom sheet sizing * always lock when navigating away
This commit is contained in:
@@ -11,3 +11,6 @@ const int kSyncEventBatchSize = 5000;
|
||||
// Hash batch limits
|
||||
const int kBatchHashFileLimit = 128;
|
||||
const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB
|
||||
|
||||
// Secure storage keys
|
||||
const String kSecuredPinCode = "secured_pin_code";
|
||||
|
||||
@@ -8,3 +8,5 @@ enum TextSearchType {
|
||||
filename,
|
||||
description,
|
||||
}
|
||||
|
||||
enum AssetVisibilityEnum { timeline, hidden, archive, locked }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
|
||||
@@ -45,7 +46,8 @@ class Asset {
|
||||
: remote.stack?.primaryAssetId,
|
||||
stackCount = remote.stack?.assetCount ?? 0,
|
||||
stackId = remote.stack?.id,
|
||||
thumbhash = remote.thumbhash;
|
||||
thumbhash = remote.thumbhash,
|
||||
visibility = getVisibility(remote.visibility);
|
||||
|
||||
Asset({
|
||||
this.id = Isar.autoIncrement,
|
||||
@@ -71,6 +73,7 @@ class Asset {
|
||||
this.stackCount = 0,
|
||||
this.isOffline = false,
|
||||
this.thumbhash,
|
||||
this.visibility = AssetVisibilityEnum.timeline,
|
||||
});
|
||||
|
||||
@ignore
|
||||
@@ -173,6 +176,9 @@ class Asset {
|
||||
|
||||
int stackCount;
|
||||
|
||||
@Enumerated(EnumType.ordinal)
|
||||
AssetVisibilityEnum visibility;
|
||||
|
||||
/// Returns null if the asset has no sync access to the exif info
|
||||
@ignore
|
||||
double? get aspectRatio {
|
||||
@@ -349,7 +355,8 @@ class Asset {
|
||||
a.thumbhash != thumbhash ||
|
||||
stackId != a.stackId ||
|
||||
stackCount != a.stackCount ||
|
||||
stackPrimaryAssetId == null && a.stackPrimaryAssetId != null;
|
||||
stackPrimaryAssetId == null && a.stackPrimaryAssetId != null ||
|
||||
visibility != a.visibility;
|
||||
}
|
||||
|
||||
/// Returns a new [Asset] with values from this and merged & updated with [a]
|
||||
@@ -452,6 +459,7 @@ class Asset {
|
||||
String? stackPrimaryAssetId,
|
||||
int? stackCount,
|
||||
String? thumbhash,
|
||||
AssetVisibilityEnum? visibility,
|
||||
}) =>
|
||||
Asset(
|
||||
id: id ?? this.id,
|
||||
@@ -477,6 +485,7 @@ class Asset {
|
||||
stackPrimaryAssetId: stackPrimaryAssetId ?? this.stackPrimaryAssetId,
|
||||
stackCount: stackCount ?? this.stackCount,
|
||||
thumbhash: thumbhash ?? this.thumbhash,
|
||||
visibility: visibility ?? this.visibility,
|
||||
);
|
||||
|
||||
Future<void> put(Isar db) async {
|
||||
@@ -541,8 +550,22 @@ class Asset {
|
||||
"isArchived": $isArchived,
|
||||
"isTrashed": $isTrashed,
|
||||
"isOffline": $isOffline,
|
||||
"visibility": "$visibility",
|
||||
}""";
|
||||
}
|
||||
|
||||
static getVisibility(AssetResponseDtoVisibilityEnum visibility) {
|
||||
switch (visibility) {
|
||||
case AssetResponseDtoVisibilityEnum.timeline:
|
||||
return AssetVisibilityEnum.timeline;
|
||||
case AssetResponseDtoVisibilityEnum.archive:
|
||||
return AssetVisibilityEnum.archive;
|
||||
case AssetResponseDtoVisibilityEnum.hidden:
|
||||
return AssetVisibilityEnum.hidden;
|
||||
case AssetResponseDtoVisibilityEnum.locked:
|
||||
return AssetVisibilityEnum.locked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AssetType {
|
||||
|
||||
Generated
+118
-3
@@ -118,8 +118,14 @@ const AssetSchema = CollectionSchema(
|
||||
name: r'updatedAt',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'width': PropertySchema(
|
||||
r'visibility': PropertySchema(
|
||||
id: 20,
|
||||
name: r'visibility',
|
||||
type: IsarType.byte,
|
||||
enumMap: _AssetvisibilityEnumValueMap,
|
||||
),
|
||||
r'width': PropertySchema(
|
||||
id: 21,
|
||||
name: r'width',
|
||||
type: IsarType.int,
|
||||
)
|
||||
@@ -256,7 +262,8 @@ void _assetSerialize(
|
||||
writer.writeString(offsets[17], object.thumbhash);
|
||||
writer.writeByte(offsets[18], object.type.index);
|
||||
writer.writeDateTime(offsets[19], object.updatedAt);
|
||||
writer.writeInt(offsets[20], object.width);
|
||||
writer.writeByte(offsets[20], object.visibility.index);
|
||||
writer.writeInt(offsets[21], object.width);
|
||||
}
|
||||
|
||||
Asset _assetDeserialize(
|
||||
@@ -288,7 +295,10 @@ Asset _assetDeserialize(
|
||||
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ??
|
||||
AssetType.other,
|
||||
updatedAt: reader.readDateTime(offsets[19]),
|
||||
width: reader.readIntOrNull(offsets[20]),
|
||||
visibility:
|
||||
_AssetvisibilityValueEnumMap[reader.readByteOrNull(offsets[20])] ??
|
||||
AssetVisibilityEnum.timeline,
|
||||
width: reader.readIntOrNull(offsets[21]),
|
||||
);
|
||||
return object;
|
||||
}
|
||||
@@ -342,6 +352,9 @@ P _assetDeserializeProp<P>(
|
||||
case 19:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 20:
|
||||
return (_AssetvisibilityValueEnumMap[reader.readByteOrNull(offset)] ??
|
||||
AssetVisibilityEnum.timeline) as P;
|
||||
case 21:
|
||||
return (reader.readIntOrNull(offset)) as P;
|
||||
default:
|
||||
throw IsarError('Unknown property with id $propertyId');
|
||||
@@ -360,6 +373,18 @@ const _AssettypeValueEnumMap = {
|
||||
2: AssetType.video,
|
||||
3: AssetType.audio,
|
||||
};
|
||||
const _AssetvisibilityEnumValueMap = {
|
||||
'timeline': 0,
|
||||
'hidden': 1,
|
||||
'archive': 2,
|
||||
'locked': 3,
|
||||
};
|
||||
const _AssetvisibilityValueEnumMap = {
|
||||
0: AssetVisibilityEnum.timeline,
|
||||
1: AssetVisibilityEnum.hidden,
|
||||
2: AssetVisibilityEnum.archive,
|
||||
3: AssetVisibilityEnum.locked,
|
||||
};
|
||||
|
||||
Id _assetGetId(Asset object) {
|
||||
return object.id;
|
||||
@@ -2477,6 +2502,59 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> visibilityEqualTo(
|
||||
AssetVisibilityEnum value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'visibility',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> visibilityGreaterThan(
|
||||
AssetVisibilityEnum value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'visibility',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> visibilityLessThan(
|
||||
AssetVisibilityEnum value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'visibility',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> visibilityBetween(
|
||||
AssetVisibilityEnum lower,
|
||||
AssetVisibilityEnum upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'visibility',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> widthIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
@@ -2791,6 +2869,18 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> sortByVisibility() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'visibility', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> sortByVisibilityDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'visibility', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> sortByWidth() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'width', Sort.asc);
|
||||
@@ -3057,6 +3147,18 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> thenByVisibility() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'visibility', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> thenByVisibilityDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'visibility', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> thenByWidth() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'width', Sort.asc);
|
||||
@@ -3201,6 +3303,12 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QDistinct> distinctByVisibility() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'visibility');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QDistinct> distinctByWidth() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'width');
|
||||
@@ -3335,6 +3443,13 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, AssetVisibilityEnum, QQueryOperations>
|
||||
visibilityProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'visibility');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, int?, QQueryOperations> widthProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'width');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
|
||||
abstract interface class IAssetApiRepository {
|
||||
@@ -15,4 +16,9 @@ abstract interface class IAssetApiRepository {
|
||||
// Future<void> delete(String id);
|
||||
|
||||
Future<List<Asset>> search({List<String> personIds = const []});
|
||||
|
||||
Future<void> updateVisibility(
|
||||
List<String> list,
|
||||
AssetVisibilityEnum visibility,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,4 +6,9 @@ abstract interface class IAuthApiRepository {
|
||||
Future<void> logout();
|
||||
|
||||
Future<void> changePassword(String newPassword);
|
||||
|
||||
Future<bool> unlockPinCode(String pinCode);
|
||||
Future<void> lockPinCode();
|
||||
|
||||
Future<void> setupPinCode(String pinCode);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:immich_mobile/models/auth/biometric_status.model.dart';
|
||||
|
||||
abstract interface class IBiometricRepository {
|
||||
Future<BiometricStatus> getStatus();
|
||||
Future<bool> authenticate(String? message);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
abstract interface class ISecureStorageRepository {
|
||||
Future<String?> read(String key);
|
||||
Future<void> write(String key, String value);
|
||||
Future<void> delete(String key);
|
||||
}
|
||||
@@ -31,4 +31,9 @@ abstract class ITimelineRepository {
|
||||
);
|
||||
|
||||
Stream<RenderList> watchAssetSelectionTimeline(String userId);
|
||||
|
||||
Stream<RenderList> watchLockedTimeline(
|
||||
String userId,
|
||||
GroupAssetsBy groupAssetsBy,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/locale_provider.dart';
|
||||
import 'package:immich_mobile/providers/theme.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
|
||||
import 'package:immich_mobile/routing/app_navigation_observer.dart';
|
||||
import 'package:immich_mobile/services/background.service.dart';
|
||||
import 'package:immich_mobile/services/local_notification.service.dart';
|
||||
import 'package:immich_mobile/theme/dynamic_theme.dart';
|
||||
@@ -219,7 +219,7 @@ class ImmichAppState extends ConsumerState<ImmichApp>
|
||||
),
|
||||
routeInformationParser: router.defaultRouteParser(),
|
||||
routerDelegate: router.delegate(
|
||||
navigatorObservers: () => [TabNavigationObserver(ref: ref)],
|
||||
navigatorObservers: () => [AppNavigationObserver(ref: ref)],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
class BiometricStatus {
|
||||
final List<BiometricType> availableBiometrics;
|
||||
final bool canAuthenticate;
|
||||
|
||||
const BiometricStatus({
|
||||
required this.availableBiometrics,
|
||||
required this.canAuthenticate,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'BiometricStatus(availableBiometrics: $availableBiometrics, canAuthenticate: $canAuthenticate)';
|
||||
|
||||
BiometricStatus copyWith({
|
||||
List<BiometricType>? availableBiometrics,
|
||||
bool? canAuthenticate,
|
||||
}) {
|
||||
return BiometricStatus(
|
||||
availableBiometrics: availableBiometrics ?? this.availableBiometrics,
|
||||
canAuthenticate: canAuthenticate ?? this.canAuthenticate,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant BiometricStatus other) {
|
||||
if (identical(this, other)) return true;
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return listEquals(other.availableBiometrics, availableBiometrics) &&
|
||||
other.canAuthenticate == canAuthenticate;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => availableBiometrics.hashCode ^ canAuthenticate.hashCode;
|
||||
}
|
||||
@@ -140,6 +140,19 @@ class QuickAccessButtons extends ConsumerWidget {
|
||||
),
|
||||
onTap: () => context.pushRoute(FolderRoute()),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.lock_outline_rounded,
|
||||
size: 26,
|
||||
),
|
||||
title: Text(
|
||||
'locked_folder'.tr(),
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
onTap: () => context.pushRoute(const LockedRoute()),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.group_outlined,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
|
||||
|
||||
@RoutePage()
|
||||
class LockedPage extends HookConsumerWidget {
|
||||
const LockedPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appLifeCycle = useAppLifecycleState();
|
||||
final showOverlay = useState(false);
|
||||
final authProviderNotifier = ref.read(authProvider.notifier);
|
||||
// lock the page when it is destroyed
|
||||
useEffect(
|
||||
() {
|
||||
return () {
|
||||
authProviderNotifier.lockPinCode();
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
if (context.mounted) {
|
||||
if (appLifeCycle == AppLifecycleState.resumed) {
|
||||
showOverlay.value = false;
|
||||
} else {
|
||||
showOverlay.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[appLifeCycle],
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: ref.watch(multiselectProvider) ? null : const LockPageAppBar(),
|
||||
body: showOverlay.value
|
||||
? const SizedBox()
|
||||
: MultiselectGrid(
|
||||
renderListProvider: lockedTimelineProvider,
|
||||
topWidget: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'no_locked_photos_message'.tr(),
|
||||
style: context.textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
editEnabled: false,
|
||||
favoriteEnabled: false,
|
||||
unfavorite: false,
|
||||
archiveEnabled: false,
|
||||
stackEnabled: false,
|
||||
unarchive: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LockPageAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
const LockPageAppBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
ref.read(authProvider.notifier).lockPinCode();
|
||||
context.maybePop();
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||
),
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
title: const Text(
|
||||
'locked_folder',
|
||||
).tr(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/local_auth.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/forms/pin_registration_form.dart';
|
||||
import 'package:immich_mobile/widgets/forms/pin_verification_form.dart';
|
||||
|
||||
@RoutePage()
|
||||
class PinAuthPage extends HookConsumerWidget {
|
||||
final bool createPinCode;
|
||||
|
||||
const PinAuthPage({super.key, this.createPinCode = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final localAuthState = ref.watch(localAuthProvider);
|
||||
final showPinRegistrationForm = useState(createPinCode);
|
||||
|
||||
Future<void> registerBiometric(String pinCode) async {
|
||||
final isRegistered =
|
||||
await ref.read(localAuthProvider.notifier).registerBiometric(
|
||||
context,
|
||||
pinCode,
|
||||
);
|
||||
|
||||
if (isRegistered) {
|
||||
context.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'biometric_auth_enabled'.tr(),
|
||||
style: context.textTheme.labelLarge,
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
backgroundColor: context.colorScheme.primaryContainer,
|
||||
),
|
||||
);
|
||||
|
||||
context.replaceRoute(const LockedRoute());
|
||||
}
|
||||
}
|
||||
|
||||
enableBiometricAuth() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (buildContext) {
|
||||
return SimpleDialog(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PinVerificationForm(
|
||||
description: 'enable_biometric_auth_description'.tr(),
|
||||
onSuccess: (pinCode) {
|
||||
Navigator.pop(buildContext);
|
||||
registerBiometric(pinCode);
|
||||
},
|
||||
autoFocus: true,
|
||||
icon: Icons.fingerprint_rounded,
|
||||
successIcon: Icons.fingerprint_rounded,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('locked_folder'.tr()),
|
||||
),
|
||||
body: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 36.0),
|
||||
child: showPinRegistrationForm.value
|
||||
? Center(
|
||||
child: PinRegistrationForm(
|
||||
onDone: () => showPinRegistrationForm.value = false,
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
Center(
|
||||
child: PinVerificationForm(
|
||||
autoFocus: true,
|
||||
onSuccess: (_) =>
|
||||
context.replaceRoute(const LockedRoute()),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (localAuthState.canAuthenticate) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(
|
||||
Icons.fingerprint,
|
||||
size: 28,
|
||||
),
|
||||
onPressed: enableBiometricAuth,
|
||||
label: Text(
|
||||
'use_biometric'.tr(),
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.primaryColor,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
@@ -170,6 +171,13 @@ class AssetNotifier extends StateNotifier<bool> {
|
||||
status ??= !assets.every((a) => a.isArchived);
|
||||
return _assetService.changeArchiveStatus(assets, status);
|
||||
}
|
||||
|
||||
Future<void> setLockedView(
|
||||
List<Asset> selection,
|
||||
AssetVisibilityEnum visibility,
|
||||
) {
|
||||
return _assetService.setVisibility(selection, visibility);
|
||||
}
|
||||
}
|
||||
|
||||
final assetDetailProvider =
|
||||
|
||||
@@ -188,4 +188,16 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
Future<String?> setOpenApiServiceEndpoint() {
|
||||
return _authService.setOpenApiServiceEndpoint();
|
||||
}
|
||||
|
||||
Future<bool> unlockPinCode(String pinCode) {
|
||||
return _authService.unlockPinCode(pinCode);
|
||||
}
|
||||
|
||||
Future<void> lockPinCode() {
|
||||
return _authService.lockPinCode();
|
||||
}
|
||||
|
||||
Future<void> setupPinCode(String pinCode) {
|
||||
return _authService.setupPinCode(pinCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/auth/biometric_status.model.dart';
|
||||
import 'package:immich_mobile/services/local_auth.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final localAuthProvider =
|
||||
StateNotifierProvider<LocalAuthNotifier, BiometricStatus>((ref) {
|
||||
return LocalAuthNotifier(
|
||||
ref.watch(localAuthServiceProvider),
|
||||
ref.watch(secureStorageServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class LocalAuthNotifier extends StateNotifier<BiometricStatus> {
|
||||
final LocalAuthService _localAuthService;
|
||||
final SecureStorageService _secureStorageService;
|
||||
|
||||
final _log = Logger("LocalAuthNotifier");
|
||||
|
||||
LocalAuthNotifier(this._localAuthService, this._secureStorageService)
|
||||
: super(
|
||||
const BiometricStatus(
|
||||
availableBiometrics: [],
|
||||
canAuthenticate: false,
|
||||
),
|
||||
) {
|
||||
_localAuthService.getStatus().then((value) {
|
||||
state = state.copyWith(
|
||||
canAuthenticate: value.canAuthenticate,
|
||||
availableBiometrics: value.availableBiometrics,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> registerBiometric(BuildContext context, String pinCode) async {
|
||||
final isAuthenticated =
|
||||
await authenticate(context, 'Authenticate to enable biometrics');
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await _secureStorageService.write(kSecuredPinCode, pinCode);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> authenticate(BuildContext context, String? message) async {
|
||||
String errorMessage = "";
|
||||
|
||||
try {
|
||||
return await _localAuthService.authenticate(message);
|
||||
} on PlatformException catch (error) {
|
||||
switch (error.code) {
|
||||
case "NotEnrolled":
|
||||
_log.warning("User is not enrolled in biometrics");
|
||||
errorMessage = "biometric_no_options".tr();
|
||||
break;
|
||||
case "NotAvailable":
|
||||
_log.warning("Biometric authentication is not available");
|
||||
errorMessage = "biometric_not_available".tr();
|
||||
break;
|
||||
case "LockedOut":
|
||||
_log.warning("User is locked out of biometric authentication");
|
||||
errorMessage = "biometric_locked_out".tr();
|
||||
break;
|
||||
default:
|
||||
_log.warning("Failed to authenticate with unknown reason");
|
||||
errorMessage = 'failed_to_authenticate'.tr();
|
||||
}
|
||||
} catch (error) {
|
||||
_log.warning("Error during authentication: $error");
|
||||
errorMessage = 'failed_to_authenticate'.tr();
|
||||
} finally {
|
||||
if (errorMessage.isNotEmpty) {
|
||||
context.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
errorMessage,
|
||||
style: context.textTheme.labelLarge,
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
backgroundColor: context.colorScheme.errorContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
final inLockedViewProvider = StateProvider<bool>((ref) => false);
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
final secureStorageProvider =
|
||||
StateNotifierProvider<SecureStorageProvider, void>((ref) {
|
||||
return SecureStorageProvider();
|
||||
});
|
||||
|
||||
class SecureStorageProvider extends StateNotifier<void> {
|
||||
SecureStorageProvider() : super(null);
|
||||
}
|
||||
@@ -73,3 +73,8 @@ final assetsTimelineProvider =
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
final lockedTimelineProvider = StreamProvider<RenderList>((ref) {
|
||||
final timelineService = ref.watch(timelineServiceProvider);
|
||||
return timelineService.watchLockedTimelineProvider();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/interfaces/asset_api.interface.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
@@ -48,4 +49,27 @@ class AssetApiRepository extends ApiRepository implements IAssetApiRepository {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateVisibility(
|
||||
List<String> ids,
|
||||
AssetVisibilityEnum visibility,
|
||||
) async {
|
||||
return _api.updateAssets(
|
||||
AssetBulkUpdateDto(ids: ids, visibility: _mapVisibility(visibility)),
|
||||
);
|
||||
}
|
||||
|
||||
_mapVisibility(AssetVisibilityEnum visibility) {
|
||||
switch (visibility) {
|
||||
case AssetVisibilityEnum.timeline:
|
||||
return AssetVisibility.timeline;
|
||||
case AssetVisibilityEnum.hidden:
|
||||
return AssetVisibility.hidden;
|
||||
case AssetVisibilityEnum.locked:
|
||||
return AssetVisibility.locked;
|
||||
case AssetVisibilityEnum.archive:
|
||||
return AssetVisibility.archive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,4 +55,26 @@ class AuthApiRepository extends ApiRepository implements IAuthApiRepository {
|
||||
userId: dto.userId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> unlockPinCode(String pinCode) async {
|
||||
try {
|
||||
await _apiService.authenticationApi
|
||||
.unlockAuthSession(SessionUnlockDto(pinCode: pinCode));
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setupPinCode(String pinCode) {
|
||||
return _apiService.authenticationApi
|
||||
.setupPinCode(PinCodeSetupDto(pinCode: pinCode));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> lockPinCode() {
|
||||
return _apiService.authenticationApi.lockAuthSession();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/interfaces/biometric.interface.dart';
|
||||
import 'package:immich_mobile/models/auth/biometric_status.model.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
final biometricRepositoryProvider =
|
||||
Provider((ref) => BiometricRepository(LocalAuthentication()));
|
||||
|
||||
class BiometricRepository implements IBiometricRepository {
|
||||
final LocalAuthentication _localAuth;
|
||||
|
||||
BiometricRepository(this._localAuth);
|
||||
|
||||
@override
|
||||
Future<BiometricStatus> getStatus() async {
|
||||
final bool canAuthenticateWithBiometrics =
|
||||
await _localAuth.canCheckBiometrics;
|
||||
final bool canAuthenticate =
|
||||
canAuthenticateWithBiometrics || await _localAuth.isDeviceSupported();
|
||||
final availableBiometric = await _localAuth.getAvailableBiometrics();
|
||||
|
||||
return BiometricStatus(
|
||||
canAuthenticate: canAuthenticate,
|
||||
availableBiometrics: availableBiometric,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> authenticate(String? message) async {
|
||||
return _localAuth.authenticate(
|
||||
localizedReason: message ?? 'please_auth_to_access'.tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/interfaces/secure_storage.interface.dart';
|
||||
|
||||
final secureStorageRepositoryProvider =
|
||||
Provider((ref) => SecureStorageRepository(const FlutterSecureStorage()));
|
||||
|
||||
class SecureStorageRepository implements ISecureStorageRepository {
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
|
||||
SecureStorageRepository(this._secureStorage);
|
||||
|
||||
@override
|
||||
Future<String?> read(String key) {
|
||||
return _secureStorage.read(key: key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String key, String value) {
|
||||
return _secureStorage.write(key: key, value: value);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) {
|
||||
return _secureStorage.delete(key: key);
|
||||
}
|
||||
}
|
||||
@@ -45,8 +45,8 @@ class TimelineRepository extends DatabaseRepository
|
||||
.where()
|
||||
.ownerIdEqualToAnyChecksum(fastHash(userId))
|
||||
.filter()
|
||||
.isArchivedEqualTo(true)
|
||||
.isTrashedEqualTo(false)
|
||||
.visibilityEqualTo(AssetVisibilityEnum.archive)
|
||||
.sortByFileCreatedAtDesc();
|
||||
|
||||
return _watchRenderList(query, GroupAssetsBy.none);
|
||||
@@ -59,6 +59,8 @@ class TimelineRepository extends DatabaseRepository
|
||||
.ownerIdEqualToAnyChecksum(fastHash(userId))
|
||||
.filter()
|
||||
.isFavoriteEqualTo(true)
|
||||
.not()
|
||||
.visibilityEqualTo(AssetVisibilityEnum.locked)
|
||||
.isTrashedEqualTo(false)
|
||||
.sortByFileCreatedAtDesc();
|
||||
|
||||
@@ -94,8 +96,8 @@ class TimelineRepository extends DatabaseRepository
|
||||
Stream<RenderList> watchAllVideosTimeline() {
|
||||
final query = db.assets
|
||||
.filter()
|
||||
.isArchivedEqualTo(false)
|
||||
.isTrashedEqualTo(false)
|
||||
.visibilityEqualTo(AssetVisibilityEnum.timeline)
|
||||
.typeEqualTo(AssetType.video)
|
||||
.sortByFileCreatedAtDesc();
|
||||
|
||||
@@ -111,9 +113,9 @@ class TimelineRepository extends DatabaseRepository
|
||||
.where()
|
||||
.ownerIdEqualToAnyChecksum(fastHash(userId))
|
||||
.filter()
|
||||
.isArchivedEqualTo(false)
|
||||
.isTrashedEqualTo(false)
|
||||
.stackPrimaryAssetIdIsNull()
|
||||
.visibilityEqualTo(AssetVisibilityEnum.timeline)
|
||||
.sortByFileCreatedAtDesc();
|
||||
|
||||
return _watchRenderList(query, groupAssetByOption);
|
||||
@@ -129,8 +131,8 @@ class TimelineRepository extends DatabaseRepository
|
||||
.where()
|
||||
.anyOf(isarUserIds, (qb, id) => qb.ownerIdEqualToAnyChecksum(id))
|
||||
.filter()
|
||||
.isArchivedEqualTo(false)
|
||||
.isTrashedEqualTo(false)
|
||||
.visibilityEqualTo(AssetVisibilityEnum.timeline)
|
||||
.stackPrimaryAssetIdIsNull()
|
||||
.sortByFileCreatedAtDesc();
|
||||
return _watchRenderList(query, groupAssetByOption);
|
||||
@@ -151,6 +153,7 @@ class TimelineRepository extends DatabaseRepository
|
||||
.remoteIdIsNotNull()
|
||||
.filter()
|
||||
.ownerIdEqualTo(fastHash(userId))
|
||||
.visibilityEqualTo(AssetVisibilityEnum.timeline)
|
||||
.isTrashedEqualTo(false)
|
||||
.stackPrimaryAssetIdIsNull()
|
||||
.sortByFileCreatedAtDesc();
|
||||
@@ -158,6 +161,22 @@ class TimelineRepository extends DatabaseRepository
|
||||
return _watchRenderList(query, GroupAssetsBy.none);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<RenderList> watchLockedTimeline(
|
||||
String userId,
|
||||
GroupAssetsBy getGroupByOption,
|
||||
) {
|
||||
final query = db.assets
|
||||
.where()
|
||||
.ownerIdEqualToAnyChecksum(fastHash(userId))
|
||||
.filter()
|
||||
.visibilityEqualTo(AssetVisibilityEnum.locked)
|
||||
.isTrashedEqualTo(false)
|
||||
.sortByFileCreatedAtDesc();
|
||||
|
||||
return _watchRenderList(query, getGroupByOption);
|
||||
}
|
||||
|
||||
Stream<RenderList> _watchRenderList(
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> query,
|
||||
GroupAssetsBy groupAssetsBy,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class AppNavigationObserver extends AutoRouterObserver {
|
||||
/// Riverpod Instance
|
||||
final WidgetRef ref;
|
||||
|
||||
AppNavigationObserver({
|
||||
required this.ref,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> didChangeTabRoute(
|
||||
TabPageRoute route,
|
||||
TabPageRoute previousRoute,
|
||||
) async {
|
||||
Future(
|
||||
() => ref.read(inLockedViewProvider.notifier).state = false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didPush(Route route, Route? previousRoute) {
|
||||
_handleLockedViewState(route, previousRoute);
|
||||
}
|
||||
|
||||
_handleLockedViewState(Route route, Route? previousRoute) {
|
||||
final isInLockedView = ref.read(inLockedViewProvider);
|
||||
final isFromLockedViewToDetailView =
|
||||
route.settings.name == GalleryViewerRoute.name &&
|
||||
previousRoute?.settings.name == LockedRoute.name;
|
||||
|
||||
final isFromDetailViewToInfoPanelView = route.settings.name == null &&
|
||||
previousRoute?.settings.name == GalleryViewerRoute.name &&
|
||||
isInLockedView;
|
||||
|
||||
if (route.settings.name == LockedRoute.name ||
|
||||
isFromLockedViewToDetailView ||
|
||||
isFromDetailViewToInfoPanelView) {
|
||||
Future(
|
||||
() => ref.read(inLockedViewProvider.notifier).state = true,
|
||||
);
|
||||
} else {
|
||||
Future(
|
||||
() => ref.read(inLockedViewProvider.notifier).state = false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/local_auth.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:local_auth/error_codes.dart' as auth_error;
|
||||
import 'package:logging/logging.dart';
|
||||
// ignore: import_rule_openapi
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class LockedGuard extends AutoRouteGuard {
|
||||
final ApiService _apiService;
|
||||
final SecureStorageService _secureStorageService;
|
||||
final LocalAuthService _localAuth;
|
||||
final _log = Logger("AuthGuard");
|
||||
|
||||
LockedGuard(
|
||||
this._apiService,
|
||||
this._secureStorageService,
|
||||
this._localAuth,
|
||||
);
|
||||
|
||||
@override
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) async {
|
||||
final authStatus = await _apiService.authenticationApi.getAuthStatus();
|
||||
|
||||
if (authStatus == null) {
|
||||
resolver.next(false);
|
||||
return;
|
||||
}
|
||||
|
||||
/// Check if a pincode has been created but this user. Show the form to create if not exist
|
||||
if (!authStatus.pinCode) {
|
||||
router.push(PinAuthRoute(createPinCode: true));
|
||||
}
|
||||
|
||||
if (authStatus.isElevated) {
|
||||
resolver.next(true);
|
||||
return;
|
||||
}
|
||||
|
||||
/// Check if the user has the pincode saved in secure storage, meaning
|
||||
/// the user has enabled the biometric authentication
|
||||
final securePinCode = await _secureStorageService.read(kSecuredPinCode);
|
||||
if (securePinCode == null) {
|
||||
router.push(PinAuthRoute());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final bool isAuth = await _localAuth.authenticate();
|
||||
|
||||
if (!isAuth) {
|
||||
resolver.next(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await _apiService.authenticationApi.unlockAuthSession(
|
||||
SessionUnlockDto(pinCode: securePinCode),
|
||||
);
|
||||
|
||||
resolver.next(true);
|
||||
} on PlatformException catch (error) {
|
||||
switch (error.code) {
|
||||
case auth_error.notAvailable:
|
||||
_log.severe("notAvailable: $error");
|
||||
break;
|
||||
case auth_error.notEnrolled:
|
||||
_log.severe("not enrolled");
|
||||
break;
|
||||
default:
|
||||
_log.severe("error");
|
||||
break;
|
||||
}
|
||||
|
||||
resolver.next(false);
|
||||
} on ApiException {
|
||||
// PIN code has changed, need to re-enter to access
|
||||
await _secureStorageService.delete(kSecuredPinCode);
|
||||
router.push(PinAuthRoute());
|
||||
} catch (error) {
|
||||
_log.severe("Failed to access locked page", error);
|
||||
resolver.next(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ import 'package:immich_mobile/pages/library/favorite.page.dart';
|
||||
import 'package:immich_mobile/pages/library/folder/folder.page.dart';
|
||||
import 'package:immich_mobile/pages/library/library.page.dart';
|
||||
import 'package:immich_mobile/pages/library/local_albums.page.dart';
|
||||
import 'package:immich_mobile/pages/library/locked/locked.page.dart';
|
||||
import 'package:immich_mobile/pages/library/locked/pin_auth.page.dart';
|
||||
import 'package:immich_mobile/pages/library/partner/partner.page.dart';
|
||||
import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart';
|
||||
import 'package:immich_mobile/pages/library/people/people_collection.page.dart';
|
||||
@@ -67,24 +69,41 @@ import 'package:immich_mobile/routing/auth_guard.dart';
|
||||
import 'package:immich_mobile/routing/backup_permission_guard.dart';
|
||||
import 'package:immich_mobile/routing/custom_transition_builders.dart';
|
||||
import 'package:immich_mobile/routing/duplicate_guard.dart';
|
||||
import 'package:immich_mobile/routing/locked_guard.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/local_auth.service.dart';
|
||||
import 'package:immich_mobile/services/secure_storage.service.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
part 'router.gr.dart';
|
||||
|
||||
final appRouterProvider = Provider(
|
||||
(ref) => AppRouter(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(galleryPermissionNotifier.notifier),
|
||||
ref.watch(secureStorageServiceProvider),
|
||||
ref.watch(localAuthServiceProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@AutoRouterConfig(replaceInRouteName: 'Page,Route')
|
||||
class AppRouter extends RootStackRouter {
|
||||
late final AuthGuard _authGuard;
|
||||
late final DuplicateGuard _duplicateGuard;
|
||||
late final BackupPermissionGuard _backupPermissionGuard;
|
||||
late final LockedGuard _lockedGuard;
|
||||
|
||||
AppRouter(
|
||||
ApiService apiService,
|
||||
GalleryPermissionNotifier galleryPermissionNotifier,
|
||||
SecureStorageService secureStorageService,
|
||||
LocalAuthService localAuthService,
|
||||
) {
|
||||
_authGuard = AuthGuard(apiService);
|
||||
_duplicateGuard = DuplicateGuard();
|
||||
_lockedGuard =
|
||||
LockedGuard(apiService, secureStorageService, localAuthService);
|
||||
_backupPermissionGuard = BackupPermissionGuard(galleryPermissionNotifier);
|
||||
}
|
||||
|
||||
@@ -289,12 +308,13 @@ class AppRouter extends RootStackRouter {
|
||||
page: ShareIntentRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
),
|
||||
AutoRoute(
|
||||
page: LockedRoute.page,
|
||||
guards: [_authGuard, _lockedGuard, _duplicateGuard],
|
||||
),
|
||||
AutoRoute(
|
||||
page: PinAuthRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
final appRouterProvider = Provider(
|
||||
(ref) => AppRouter(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(galleryPermissionNotifier.notifier),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -956,6 +956,25 @@ class LocalAlbumsRoute extends PageRouteInfo<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [LockedPage]
|
||||
class LockedRoute extends PageRouteInfo<void> {
|
||||
const LockedRoute({List<PageRouteInfo>? children})
|
||||
: super(
|
||||
LockedRoute.name,
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'LockedRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const LockedPage();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [LoginPage]
|
||||
class LoginRoute extends PageRouteInfo<void> {
|
||||
@@ -1359,6 +1378,53 @@ class PhotosRoute extends PageRouteInfo<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [PinAuthPage]
|
||||
class PinAuthRoute extends PageRouteInfo<PinAuthRouteArgs> {
|
||||
PinAuthRoute({
|
||||
Key? key,
|
||||
bool createPinCode = false,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
PinAuthRoute.name,
|
||||
args: PinAuthRouteArgs(
|
||||
key: key,
|
||||
createPinCode: createPinCode,
|
||||
),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'PinAuthRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args =
|
||||
data.argsAs<PinAuthRouteArgs>(orElse: () => const PinAuthRouteArgs());
|
||||
return PinAuthPage(
|
||||
key: args.key,
|
||||
createPinCode: args.createPinCode,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class PinAuthRouteArgs {
|
||||
const PinAuthRouteArgs({
|
||||
this.key,
|
||||
this.createPinCode = false,
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final bool createPinCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PinAuthRouteArgs{key: $key, createPinCode: $createPinCode}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [PlacesCollectionPage]
|
||||
class PlacesCollectionRoute extends PageRouteInfo<PlacesCollectionRouteArgs> {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
|
||||
class TabNavigationObserver extends AutoRouterObserver {
|
||||
/// Riverpod Instance
|
||||
final WidgetRef ref;
|
||||
|
||||
TabNavigationObserver({
|
||||
required this.ref,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> didChangeTabRoute(
|
||||
TabPageRoute route,
|
||||
TabPageRoute previousRoute,
|
||||
) async {
|
||||
if (route.name == 'HomeRoute') {
|
||||
ref.invalidate(memoryFutureProvider);
|
||||
Future(() => ref.read(assetProvider.notifier).getAllAsset());
|
||||
|
||||
// Update user info
|
||||
try {
|
||||
ref.read(userServiceProvider).refreshMyUser();
|
||||
ref.read(serverInfoProvider.notifier).getServerVersion();
|
||||
} catch (e) {
|
||||
debugPrint("Error refreshing user info $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/exif.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/user.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
@@ -239,6 +240,9 @@ class AssetService {
|
||||
|
||||
for (var element in assets) {
|
||||
element.isArchived = isArchived;
|
||||
element.visibility = isArchived
|
||||
? AssetVisibilityEnum.archive
|
||||
: AssetVisibilityEnum.timeline;
|
||||
}
|
||||
|
||||
await _syncService.upsertAssetsWithExif(assets);
|
||||
@@ -458,6 +462,7 @@ class AssetService {
|
||||
bool shouldDeletePermanently = false,
|
||||
}) async {
|
||||
final candidates = assets.where((a) => a.isRemote);
|
||||
|
||||
if (candidates.isEmpty) {
|
||||
return;
|
||||
}
|
||||
@@ -475,6 +480,7 @@ class AssetService {
|
||||
.where((asset) => asset.storage == AssetState.merged)
|
||||
.map((asset) {
|
||||
asset.remoteId = null;
|
||||
asset.visibility = AssetVisibilityEnum.timeline;
|
||||
return asset;
|
||||
})
|
||||
: assets.where((asset) => asset.isRemote).map((asset) {
|
||||
@@ -529,4 +535,21 @@ class AssetService {
|
||||
final me = _userService.getMyUser();
|
||||
return _assetRepository.getMotionAssets(me.id);
|
||||
}
|
||||
|
||||
Future<void> setVisibility(
|
||||
List<Asset> assets,
|
||||
AssetVisibilityEnum visibility,
|
||||
) async {
|
||||
await _assetApiRepository.updateVisibility(
|
||||
assets.map((asset) => asset.remoteId!).toList(),
|
||||
visibility,
|
||||
);
|
||||
|
||||
final updatedAssets = assets.map((asset) {
|
||||
asset.visibility = visibility;
|
||||
return asset;
|
||||
}).toList();
|
||||
|
||||
await _assetRepository.updateAll(updatedAssets);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,4 +201,16 @@ class AuthService {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> unlockPinCode(String pinCode) {
|
||||
return _authApiRepository.unlockPinCode(pinCode);
|
||||
}
|
||||
|
||||
Future<void> lockPinCode() {
|
||||
return _authApiRepository.lockPinCode();
|
||||
}
|
||||
|
||||
Future<void> setupPinCode(String pinCode) {
|
||||
return _authApiRepository.setupPinCode(pinCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/interfaces/biometric.interface.dart';
|
||||
import 'package:immich_mobile/models/auth/biometric_status.model.dart';
|
||||
import 'package:immich_mobile/repositories/biometric.repository.dart';
|
||||
|
||||
final localAuthServiceProvider = Provider(
|
||||
(ref) => LocalAuthService(
|
||||
ref.watch(biometricRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class LocalAuthService {
|
||||
// final _log = Logger("LocalAuthService");
|
||||
|
||||
final IBiometricRepository _biometricRepository;
|
||||
|
||||
LocalAuthService(this._biometricRepository);
|
||||
|
||||
Future<BiometricStatus> getStatus() {
|
||||
return _biometricRepository.getStatus();
|
||||
}
|
||||
|
||||
Future<bool> authenticate([String? message]) async {
|
||||
return _biometricRepository.authenticate(message);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/interfaces/asset.interface.dart';
|
||||
import 'package:immich_mobile/models/memories/memory.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/translation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final memoryServiceProvider = StateProvider<MemoryService>((ref) {
|
||||
@@ -40,10 +40,7 @@ class MemoryService {
|
||||
.getAllByRemoteId(memory.assets.map((e) => e.id));
|
||||
final yearsAgo = now.year - memory.data.year;
|
||||
if (dbAssets.isNotEmpty) {
|
||||
final String title = yearsAgo <= 1
|
||||
? 'memories_year_ago'.tr()
|
||||
: 'memories_years_ago'
|
||||
.tr(namedArgs: {'years': yearsAgo.toString()});
|
||||
final String title = t('years_ago', {'years': yearsAgo.toString()});
|
||||
memories.add(
|
||||
Memory(
|
||||
title: title,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/interfaces/secure_storage.interface.dart';
|
||||
import 'package:immich_mobile/repositories/secure_storage.repository.dart';
|
||||
|
||||
final secureStorageServiceProvider = Provider(
|
||||
(ref) => SecureStorageService(
|
||||
ref.watch(secureStorageRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class SecureStorageService {
|
||||
// final _log = Logger("LocalAuthService");
|
||||
|
||||
final ISecureStorageRepository _secureStorageRepository;
|
||||
|
||||
SecureStorageService(this._secureStorageRepository);
|
||||
|
||||
Future<void> write(String key, String value) async {
|
||||
await _secureStorageRepository.write(key, value);
|
||||
}
|
||||
|
||||
Future<void> delete(String key) async {
|
||||
await _secureStorageRepository.delete(key);
|
||||
}
|
||||
|
||||
Future<String?> read(String key) async {
|
||||
return _secureStorageRepository.read(key);
|
||||
}
|
||||
}
|
||||
@@ -105,4 +105,13 @@ class TimelineService {
|
||||
return GroupAssetsBy
|
||||
.values[_appSettingsService.getSetting(AppSettingsEnum.groupAssetsBy)];
|
||||
}
|
||||
|
||||
Stream<RenderList> watchLockedTimelineProvider() async* {
|
||||
final user = _userService.getMyUser();
|
||||
|
||||
yield* _timelineRepository.watchLockedTimeline(
|
||||
user.id,
|
||||
_getGroupByOption(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ ThemeData getThemeData({
|
||||
titleTextStyle: TextStyle(
|
||||
color: colorScheme.primary,
|
||||
fontFamily: _getFontFamilyFromLocale(locale),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
),
|
||||
backgroundColor:
|
||||
@@ -54,28 +54,28 @@ ThemeData getThemeData({
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
displayMedium: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
displaySmall: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
titleSmall: TextStyle(
|
||||
fontSize: 16.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
fontSize: 18.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
titleLarge: TextStyle(
|
||||
fontSize: 26.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
|
||||
@@ -20,7 +20,7 @@ import 'package:isar/isar.dart';
|
||||
// ignore: import_rule_photo_manager
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
const int targetVersion = 10;
|
||||
const int targetVersion = 11;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
||||
final int version = Store.get(StoreKey.version, targetVersion);
|
||||
|
||||
@@ -32,6 +32,11 @@ dynamic upgradeDto(dynamic value, String targetType) {
|
||||
addDefault(value, 'visibility', AssetVisibility.timeline);
|
||||
}
|
||||
break;
|
||||
case 'AssetResponseDto':
|
||||
if (value is Map) {
|
||||
addDefault(value, 'visibility', 'timeline');
|
||||
}
|
||||
break;
|
||||
case 'UserAdminResponseDto':
|
||||
if (value is Map) {
|
||||
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/asset_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
@@ -157,3 +158,29 @@ Future<void> handleEditLocation(
|
||||
|
||||
ref.read(assetServiceProvider).changeLocation(selection.toList(), location);
|
||||
}
|
||||
|
||||
Future<void> handleSetAssetsVisibility(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
AssetVisibilityEnum visibility,
|
||||
List<Asset> selection, {
|
||||
ToastGravity toastGravity = ToastGravity.BOTTOM,
|
||||
}) async {
|
||||
if (selection.isNotEmpty) {
|
||||
await ref
|
||||
.watch(assetProvider.notifier)
|
||||
.setLockedView(selection, visibility);
|
||||
|
||||
final assetOrAssets = selection.length > 1 ? 'assets' : 'asset';
|
||||
final toastMessage = visibility == AssetVisibilityEnum.locked
|
||||
? 'Added ${selection.length} $assetOrAssets to locked folder'
|
||||
: 'Removed ${selection.length} $assetOrAssets from locked folder';
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: toastMessage,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.dart';
|
||||
import 'package:immich_mobile/models/asset_selection_state.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||
@@ -37,6 +38,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
||||
final void Function()? onEditTime;
|
||||
final void Function()? onEditLocation;
|
||||
final void Function()? onRemoveFromAlbum;
|
||||
final void Function()? onToggleLocked;
|
||||
|
||||
final bool enabled;
|
||||
final bool unfavorite;
|
||||
@@ -58,6 +60,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
||||
this.onEditTime,
|
||||
this.onEditLocation,
|
||||
this.onRemoveFromAlbum,
|
||||
this.onToggleLocked,
|
||||
this.selectionAssetState = const AssetSelectionState(),
|
||||
this.enabled = true,
|
||||
this.unarchive = false,
|
||||
@@ -77,6 +80,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
||||
ref.watch(albumProvider).where((a) => a.shared).toList();
|
||||
const bottomPadding = 0.20;
|
||||
final scrollController = useDraggableScrollController();
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
|
||||
void minimize() {
|
||||
scrollController.animateTo(
|
||||
@@ -133,11 +137,12 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
||||
label: "share".tr(),
|
||||
onPressed: enabled ? () => onShare(true) : null,
|
||||
),
|
||||
ControlBoxButton(
|
||||
iconData: Icons.link_rounded,
|
||||
label: "control_bottom_app_bar_share_link".tr(),
|
||||
onPressed: enabled ? () => onShare(false) : null,
|
||||
),
|
||||
if (!isInLockedView)
|
||||
ControlBoxButton(
|
||||
iconData: Icons.link_rounded,
|
||||
label: "share_link".tr(),
|
||||
onPressed: enabled ? () => onShare(false) : null,
|
||||
),
|
||||
if (hasRemote && onArchive != null)
|
||||
ControlBoxButton(
|
||||
iconData:
|
||||
@@ -153,7 +158,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
||||
label: (unfavorite ? "unfavorite" : "favorite").tr(),
|
||||
onPressed: enabled ? onFavorite : null,
|
||||
),
|
||||
if (hasLocal && hasRemote && onDelete != null)
|
||||
if (hasLocal && hasRemote && onDelete != null && !isInLockedView)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 90),
|
||||
child: ControlBoxButton(
|
||||
@@ -166,7 +171,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
||||
enabled ? () => showForceDeleteDialog(onDelete!) : null,
|
||||
),
|
||||
),
|
||||
if (hasRemote && onDeleteServer != null)
|
||||
if (hasRemote && onDeleteServer != null && !isInLockedView)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 85),
|
||||
child: ControlBoxButton(
|
||||
@@ -189,9 +194,23 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (hasLocal && onDeleteLocal != null)
|
||||
if (isInLockedView)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 85),
|
||||
constraints: const BoxConstraints(maxWidth: 110),
|
||||
child: ControlBoxButton(
|
||||
iconData: Icons.delete_forever,
|
||||
label: "delete_dialog_title".tr(),
|
||||
onPressed: enabled
|
||||
? () => showForceDeleteDialog(
|
||||
onDeleteServer!,
|
||||
alertMsg: "delete_dialog_alert_remote",
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (hasLocal && onDeleteLocal != null && !isInLockedView)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 95),
|
||||
child: ControlBoxButton(
|
||||
iconData: Icons.no_cell_outlined,
|
||||
label: "control_bottom_app_bar_delete_from_local".tr(),
|
||||
@@ -231,6 +250,19 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
||||
onPressed: enabled ? onEditLocation : null,
|
||||
),
|
||||
),
|
||||
if (hasRemote)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 100),
|
||||
child: ControlBoxButton(
|
||||
iconData: isInLockedView
|
||||
? Icons.lock_open_rounded
|
||||
: Icons.lock_outline_rounded,
|
||||
label: isInLockedView
|
||||
? "remove_from_locked_folder".tr()
|
||||
: "move_to_locked_folder".tr(),
|
||||
onPressed: enabled ? onToggleLocked : null,
|
||||
),
|
||||
),
|
||||
if (!selectionAssetState.hasLocal &&
|
||||
selectionAssetState.selectedCount > 1 &&
|
||||
onStack != null)
|
||||
@@ -269,20 +301,40 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
||||
];
|
||||
}
|
||||
|
||||
getInitialSize() {
|
||||
if (isInLockedView) {
|
||||
return 0.20;
|
||||
}
|
||||
if (hasRemote) {
|
||||
return 0.35;
|
||||
}
|
||||
return bottomPadding;
|
||||
}
|
||||
|
||||
getMaxChildSize() {
|
||||
if (isInLockedView) {
|
||||
return 0.20;
|
||||
}
|
||||
if (hasRemote) {
|
||||
return 0.65;
|
||||
}
|
||||
return bottomPadding;
|
||||
}
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
controller: scrollController,
|
||||
initialChildSize: hasRemote ? 0.35 : bottomPadding,
|
||||
initialChildSize: getInitialSize(),
|
||||
minChildSize: bottomPadding,
|
||||
maxChildSize: hasRemote ? 0.65 : bottomPadding,
|
||||
maxChildSize: getMaxChildSize(),
|
||||
snap: true,
|
||||
builder: (
|
||||
BuildContext context,
|
||||
ScrollController scrollController,
|
||||
) {
|
||||
return Card(
|
||||
color: context.colorScheme.surfaceContainerLow,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 18.0,
|
||||
color: context.colorScheme.surfaceContainerHigh,
|
||||
surfaceTintColor: context.colorScheme.surfaceContainerHigh,
|
||||
elevation: 6.0,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
@@ -300,27 +352,27 @@ class ControlBottomAppBar extends HookConsumerWidget {
|
||||
const CustomDraggingHandle(),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 100,
|
||||
height: 120,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: renderActionButtons(),
|
||||
),
|
||||
),
|
||||
if (hasRemote)
|
||||
if (hasRemote && !isInLockedView) ...[
|
||||
const Divider(
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
thickness: 1,
|
||||
),
|
||||
if (hasRemote)
|
||||
_AddToAlbumTitleRow(
|
||||
onCreateNewAlbum: enabled ? onCreateNewAlbum : null,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasRemote)
|
||||
if (hasRemote && !isInLockedView)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: AddToAlbumSliverList(
|
||||
@@ -352,12 +404,9 @@ class _AddToAlbumTitleRow extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
Text(
|
||||
"add_to_album",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: context.textTheme.titleSmall,
|
||||
).tr(),
|
||||
TextButton.icon(
|
||||
onPressed: onCreateNewAlbum,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
||||
@@ -15,6 +16,7 @@ import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/album.service.dart';
|
||||
@@ -395,6 +397,32 @@ class MultiselectGrid extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void onToggleLockedVisibility() async {
|
||||
processing.value = true;
|
||||
try {
|
||||
final remoteAssets = ownedRemoteSelection(
|
||||
localErrorMessage: 'home_page_locked_error_local'.tr(),
|
||||
ownerErrorMessage: 'home_page_locked_error_partner'.tr(),
|
||||
);
|
||||
if (remoteAssets.isNotEmpty) {
|
||||
final isInLockedView = ref.read(inLockedViewProvider);
|
||||
final visibility = isInLockedView
|
||||
? AssetVisibilityEnum.timeline
|
||||
: AssetVisibilityEnum.locked;
|
||||
|
||||
await handleSetAssetsVisibility(
|
||||
ref,
|
||||
context,
|
||||
visibility,
|
||||
remoteAssets.toList(),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<T> Function() wrapLongRunningFun<T>(
|
||||
Future<T> Function() fun, {
|
||||
bool showOverlay = true,
|
||||
@@ -460,6 +488,7 @@ class MultiselectGrid extends HookConsumerWidget {
|
||||
onEditLocation: editEnabled ? onEditLocation : null,
|
||||
unfavorite: unfavorite,
|
||||
unarchive: unarchive,
|
||||
onToggleLocked: onToggleLockedVisibility,
|
||||
onRemoveFromAlbum: onRemoveFromAlbum != null
|
||||
? wrapLongRunningFun(
|
||||
() => onRemoveFromAlbum!(selection.value),
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -46,6 +47,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final asset = ref.watch(currentAssetProvider);
|
||||
if (asset == null) {
|
||||
return const SizedBox();
|
||||
@@ -277,7 +279,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
tooltip: 'share'.tr(),
|
||||
): (_) => shareAsset(),
|
||||
},
|
||||
if (asset.isImage)
|
||||
if (asset.isImage && !isInLockedView)
|
||||
{
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.tune_outlined),
|
||||
@@ -285,7 +287,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
tooltip: 'edit'.tr(),
|
||||
): (_) => handleEdit(),
|
||||
},
|
||||
if (isOwner)
|
||||
if (isOwner && !isInLockedView)
|
||||
{
|
||||
asset.isArchived
|
||||
? BottomNavigationBarItem(
|
||||
@@ -299,7 +301,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
tooltip: 'archive'.tr(),
|
||||
): (_) => handleArchive(),
|
||||
},
|
||||
if (isOwner && asset.stackCount > 0)
|
||||
if (isOwner && asset.stackCount > 0 && !isInLockedView)
|
||||
{
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.burst_mode_outlined),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/providers/activity_statistics.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/tab.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
@@ -39,6 +40,7 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
const double iconSize = 22.0;
|
||||
final a = ref.watch(assetWatcher(asset)).value ?? asset;
|
||||
final album = ref.watch(currentAlbumProvider);
|
||||
@@ -178,15 +180,22 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
shape: const Border(),
|
||||
actions: [
|
||||
if (asset.isRemote && isOwner) buildFavoriteButton(a),
|
||||
if (isOwner && !isInHomePage && !(isInTrash ?? false))
|
||||
if (isOwner &&
|
||||
!isInHomePage &&
|
||||
!(isInTrash ?? false) &&
|
||||
!isInLockedView)
|
||||
buildLocateButton(),
|
||||
if (asset.livePhotoVideoId != null) const MotionPhotoButton(),
|
||||
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
|
||||
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
|
||||
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed)
|
||||
if (asset.isRemote &&
|
||||
(isOwner || isPartner) &&
|
||||
!asset.isTrashed &&
|
||||
!isInLockedView)
|
||||
buildAddToAlbumButton(),
|
||||
if (asset.isTrashed) buildRestoreButton(),
|
||||
if (album != null && album.shared) buildActivitiesButton(),
|
||||
if (album != null && album.shared && !isInLockedView)
|
||||
buildActivitiesButton(),
|
||||
buildMoreInfoButton(),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -35,7 +35,9 @@ class ControlBoxButton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialButton(
|
||||
padding: const EdgeInsets.all(10),
|
||||
shape: const CircleBorder(),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
onLongPress: onLongPressed,
|
||||
minWidth: 75.0,
|
||||
@@ -47,8 +49,8 @@ class ControlBoxButton extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12.0),
|
||||
maxLines: 2,
|
||||
style: const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400),
|
||||
maxLines: 3,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -40,7 +40,7 @@ class ImmichToast {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(5.0),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
|
||||
color: context.colorScheme.surfaceContainer,
|
||||
border: Border.all(
|
||||
color: context.colorScheme.outline.withValues(alpha: .5),
|
||||
@@ -59,14 +59,23 @@ class ImmichToast {
|
||||
msg,
|
||||
style: TextStyle(
|
||||
color: getColor(toastType, context),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
positionedToastBuilder: (context, child, gravity) {
|
||||
return Positioned(
|
||||
top: gravity == ToastGravity.TOP ? 150 : null,
|
||||
bottom: gravity == ToastGravity.BOTTOM ? 150 : null,
|
||||
left: MediaQuery.of(context).size.width / 2 - 150,
|
||||
right: MediaQuery.of(context).size.width / 2 - 150,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
gravity: gravity,
|
||||
toastDuration: Duration(seconds: durationInSecond),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:pinput/pinput.dart';
|
||||
|
||||
class PinInput extends StatelessWidget {
|
||||
final Function(String)? onCompleted;
|
||||
final Function(String)? onChanged;
|
||||
final int? length;
|
||||
final bool? obscureText;
|
||||
final bool? autoFocus;
|
||||
final bool? hasError;
|
||||
final String? label;
|
||||
final TextEditingController? controller;
|
||||
|
||||
const PinInput({
|
||||
super.key,
|
||||
this.onCompleted,
|
||||
this.onChanged,
|
||||
this.length,
|
||||
this.obscureText,
|
||||
this.autoFocus,
|
||||
this.hasError,
|
||||
this.label,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
getPinSize() {
|
||||
final minimumPadding = 18.0;
|
||||
final gapWidth = 3.0;
|
||||
final screenWidth = context.width;
|
||||
final pinWidth =
|
||||
(screenWidth - (minimumPadding * 2) - (gapWidth * 5)) / (length ?? 6);
|
||||
|
||||
if (pinWidth > 60) {
|
||||
return const Size(60, 64);
|
||||
}
|
||||
|
||||
final pinHeight = pinWidth / (60 / 64);
|
||||
return Size(pinWidth, pinHeight);
|
||||
}
|
||||
|
||||
final defaultPinTheme = PinTheme(
|
||||
width: getPinSize().width,
|
||||
height: getPinSize().height,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 24,
|
||||
color: context.colorScheme.onSurface,
|
||||
fontFamily: 'Overpass Mono',
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(19)),
|
||||
border: Border.all(color: context.colorScheme.surfaceBright),
|
||||
color: context.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (label != null) ...[
|
||||
Text(
|
||||
label!,
|
||||
style: context.textTheme.displayLarge
|
||||
?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Pinput(
|
||||
controller: controller,
|
||||
forceErrorState: hasError ?? false,
|
||||
autofocus: autoFocus ?? false,
|
||||
obscureText: obscureText ?? false,
|
||||
obscuringWidget: Icon(
|
||||
Icons.vpn_key_rounded,
|
||||
color: context.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
separatorBuilder: (index) => const SizedBox(
|
||||
height: 64,
|
||||
width: 3,
|
||||
),
|
||||
cursor: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 9),
|
||||
width: 18,
|
||||
height: 2,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
defaultPinTheme: defaultPinTheme,
|
||||
focusedPinTheme: defaultPinTheme.copyWith(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(19)),
|
||||
border: Border.all(
|
||||
color: context.primaryColor.withValues(alpha: 0.5),
|
||||
width: 2,
|
||||
),
|
||||
color: context.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
),
|
||||
errorPinTheme: defaultPinTheme.copyWith(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.error.withAlpha(15),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(19)),
|
||||
border: Border.all(
|
||||
color: context.colorScheme.error.withAlpha(100),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
pinputAutovalidateMode: PinputAutovalidateMode.onSubmit,
|
||||
length: length ?? 6,
|
||||
onChanged: onChanged,
|
||||
onCompleted: onCompleted,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/widgets/forms/pin_input.dart';
|
||||
|
||||
class PinRegistrationForm extends HookConsumerWidget {
|
||||
final Function() onDone;
|
||||
|
||||
const PinRegistrationForm({
|
||||
super.key,
|
||||
required this.onDone,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasError = useState(false);
|
||||
final newPinCodeController = useTextEditingController();
|
||||
final confirmPinCodeController = useTextEditingController();
|
||||
|
||||
bool validatePinCode() {
|
||||
if (confirmPinCodeController.text.length != 6) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newPinCodeController.text != confirmPinCodeController.text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
createNewPinCode() async {
|
||||
final isValid = validatePinCode();
|
||||
if (!isValid) {
|
||||
hasError.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ref.read(authProvider.notifier).setupPinCode(
|
||||
newPinCodeController.text,
|
||||
);
|
||||
|
||||
onDone();
|
||||
} catch (error) {
|
||||
hasError.value = true;
|
||||
context.showSnackBar(
|
||||
SnackBar(content: Text(error.toString())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Form(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.pin_outlined,
|
||||
size: 64,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: context.width * 0.7,
|
||||
child: Text(
|
||||
'setup_pin_code'.tr(),
|
||||
style: context.textTheme.labelLarge!.copyWith(
|
||||
fontSize: 24,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: context.width * 0.8,
|
||||
child: Text(
|
||||
'new_pin_code_subtitle'.tr(),
|
||||
style: context.textTheme.bodyLarge!.copyWith(
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
PinInput(
|
||||
controller: newPinCodeController,
|
||||
label: 'new_pin_code'.tr(),
|
||||
length: 6,
|
||||
autoFocus: true,
|
||||
hasError: hasError.value,
|
||||
onChanged: (input) {
|
||||
if (input.length < 6) {
|
||||
hasError.value = false;
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
PinInput(
|
||||
controller: confirmPinCodeController,
|
||||
label: 'confirm_new_pin_code'.tr(),
|
||||
length: 6,
|
||||
hasError: hasError.value,
|
||||
onChanged: (input) {
|
||||
if (input.length < 6) {
|
||||
hasError.value = false;
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: createNewPinCode,
|
||||
child: Text('create'.tr()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/widgets/forms/pin_input.dart';
|
||||
|
||||
class PinVerificationForm extends HookConsumerWidget {
|
||||
final Function(String) onSuccess;
|
||||
final VoidCallback? onError;
|
||||
final bool? autoFocus;
|
||||
final String? description;
|
||||
final IconData? icon;
|
||||
final IconData? successIcon;
|
||||
|
||||
const PinVerificationForm({
|
||||
super.key,
|
||||
required this.onSuccess,
|
||||
this.onError,
|
||||
this.autoFocus,
|
||||
this.description,
|
||||
this.icon,
|
||||
this.successIcon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasError = useState(false);
|
||||
final isVerified = useState(false);
|
||||
|
||||
verifyPin(String pinCode) async {
|
||||
final isUnlocked =
|
||||
await ref.read(authProvider.notifier).unlockPinCode(pinCode);
|
||||
|
||||
if (isUnlocked) {
|
||||
isVerified.value = true;
|
||||
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
onSuccess(pinCode);
|
||||
} else {
|
||||
hasError.value = true;
|
||||
onError?.call();
|
||||
}
|
||||
}
|
||||
|
||||
return Form(
|
||||
child: Column(
|
||||
children: [
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: isVerified.value
|
||||
? Icon(
|
||||
successIcon ?? Icons.lock_open_rounded,
|
||||
size: 64,
|
||||
color: Colors.green[300],
|
||||
)
|
||||
: Icon(
|
||||
icon ?? Icons.lock_outline_rounded,
|
||||
size: 64,
|
||||
color: hasError.value
|
||||
? context.colorScheme.error
|
||||
: context.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
SizedBox(
|
||||
width: context.width * 0.7,
|
||||
child: Text(
|
||||
description ?? 'enter_your_pin_code_subtitle'.tr(),
|
||||
style: context.textTheme.labelLarge!.copyWith(
|
||||
fontSize: 18,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
PinInput(
|
||||
obscureText: true,
|
||||
autoFocus: autoFocus,
|
||||
hasError: hasError.value,
|
||||
length: 6,
|
||||
onChanged: (pinCode) {
|
||||
if (pinCode.length < 6) {
|
||||
hasError.value = false;
|
||||
}
|
||||
},
|
||||
onCompleted: verifyPin,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user