Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:flutter_checklist/checklist.dart';
import 'package:flutter_fgbg/flutter_fgbg.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'common/actions/backup.dart';
import 'common/constants/constants.dart';
import 'common/enums/supported_language.dart';
import 'common/extensions/locale_extension.dart';
Expand All @@ -21,6 +22,7 @@ import 'providers/labels/labels_list/labels_list_provider.dart';
import 'providers/labels/labels_navigation/labels_navigation_provider.dart';
import 'providers/notifiers/notifiers.dart';
import 'providers/preferences/preferences_provider.dart';
import 'services/backup/auto_backup_service.dart';

/// MaterialNotes application.
class App extends ConsumerStatefulWidget {
Expand Down Expand Up @@ -53,9 +55,14 @@ class _AppState extends ConsumerState<App> with AfterLayoutMixin<App> {
}

@override
FutureOr<void> afterFirstLayout(BuildContext context) {
Future<void> afterFirstLayout(BuildContext context) async {
// Using the context provided by afterFirstLayout doesn't work
SystemUtils().setQuickActions(rootNavigatorKey.currentContext!);

// If the backup directory is still the default, ask to select it
if (await AutoExportUtils().isAutoExportDirectoryDefault) {
await requireBackupDirectory(rootNavigatorKey.currentContext!);
}
}

@override
Expand Down
53 changes: 53 additions & 0 deletions lib/common/actions/authentication.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:local_auth/local_auth.dart';
import 'package:restart_app/restart_app.dart';

import '../constants/constants.dart';
import '../extensions/build_context_extension.dart';
import '../preferences/preference_key.dart';
import '../ui/snack_bar_utils.dart';

/// Asks the user to authenticate for the specified [reason] using the device lock method.
Future<bool> authenticate(BuildContext context, {required String reason}) async {
final localAuthentication = LocalAuthentication();

final canAuthenticate = await localAuthentication.isDeviceSupported();

// If the device has no authentication methods available,
// disable the app lock and restart it to remove the lock screen
if (!canAuthenticate) {
await PreferenceKey.lockApp.set(false);

// The Restart package crashes the app if used in debug mode
if (kReleaseMode) {
await Restart.restartApp();
}

return false;
}

bool authenticated;
try {
authenticated = await localAuthentication.authenticate(localizedReason: reason);
} on LocalAuthException catch (exception) {
if (exception.code != LocalAuthExceptionCode.userCanceled) {
logger.w("Authentication failed", exception);
}

authenticated = false;
}

// The authentication failed
if (!authenticated) {
if (!context.mounted) {
return false;
}

SnackBarUtils().show(context, text: context.l.snack_bar_authentication_failed);

return false;
}

return true;
}
33 changes: 33 additions & 0 deletions lib/common/actions/backup.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';

import '../../services/backup/auto_backup_service.dart';
import '../dialogs/require_backup_dialog.dart';
import '../files/files_utils.dart';
import '../preferences/preference_key.dart';

/// Requires the user to select a backup directory.
Future<void> requireBackupDirectory(BuildContext context) async {
final select = await showAdaptiveDialog<bool>(
context: context,
useRootNavigator: false,
builder: (context) => RequireBackupDialog(),
);

if (select == null || !select) {
return;
}

await selectBackupDirectory();
}

/// Asks the user to select a backup directory.
Future<void> selectBackupDirectory() async {
final autoExportDirectory = await selectDirectory();

if (autoExportDirectory == null) {
return;
}

await PreferenceKey.autoExportDirectory.set(autoExportDirectory);
await AutoExportUtils().setAutoExportDirectory();
}
28 changes: 28 additions & 0 deletions lib/common/actions/labels/delete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import '../../../models/label/label.dart';
import '../../../providers/labels/labels/labels_provider.dart';
import '../../dialogs/confirmation_dialog.dart';
import '../../extensions/build_context_extension.dart';
import '../authentication.dart';
import 'select.dart';

/// Deletes the [label].
Expand All @@ -23,6 +24,19 @@ Future<bool> deleteLabel(BuildContext context, WidgetRef ref, {required Label la
return false;
}

if (!context.mounted) {
return false;
}

// If required, ask for authentication
if (label.requiresAuthentication) {
final authenticated = await authenticate(context, reason: context.l.lock_page_reason_action);

if (!authenticated) {
return false;
}
}

return await ref.read(labelsProvider.notifier).delete([label]);
}

Expand All @@ -42,6 +56,20 @@ Future<bool> deleteLabels(BuildContext context, WidgetRef ref, {required List<La
return false;
}

if (!context.mounted) {
return false;
}

// If required, ask for authentication
final requiresAuthentication = labels.any((label) => label.requiresAuthentication);
if (requiresAuthentication) {
final authenticated = await authenticate(context, reason: context.l.lock_page_reason_action);

if (!authenticated) {
return false;
}
}

final succeeded = await ref.read(labelsProvider.notifier).delete(labels);

if (context.mounted) {
Expand Down
15 changes: 5 additions & 10 deletions lib/common/actions/labels/lock.dart
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:local_auth/local_auth.dart';

import '../../../models/label/label.dart';
import '../../../providers/labels/labels/labels_provider.dart';
import '../../extensions/build_context_extension.dart';
import '../../preferences/preference_key.dart';
import '../authentication.dart';
import 'select.dart';

/// Toggles whether the [labels] are locked.
Future<bool> toggleLockLabels(BuildContext context, WidgetRef ref, {required List<Label> labels}) async {
final lockLabelPreference = PreferenceKey.lockLabel.preferenceOrDefault;
final anyLocked = labels.any((label) => label.locked);

// If the lock label setting is enabled and a label was locked, then ask to authenticate
if (lockLabelPreference && anyLocked) {
final bool authenticated = await LocalAuthentication().authenticate(
localizedReason: context.l.lock_page_reason_action,
);
// If required, ask for authentication
final requiresAuthentication = labels.any((label) => label.requiresAuthentication);
if (requiresAuthentication) {
final authenticated = await authenticate(context, reason: context.l.lock_page_reason_action);

if (!authenticated) {
return false;
Expand Down
3 changes: 3 additions & 0 deletions lib/common/actions/notes/labels.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ Future<List<Label>?> selectLabels(BuildContext context, WidgetRef ref, {required
.editLabels(note, selectedLabels);

currentNoteNotifier.value = note;
// Forcefully notify the listeners
// because the note object as been modified in memory
currentNoteNotifier.notify();

return selectedLabels;
}
Expand Down
28 changes: 8 additions & 20 deletions lib/common/actions/notes/lock.dart
Original file line number Diff line number Diff line change
@@ -1,35 +1,23 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:local_auth/local_auth.dart';

import '../../../models/note/note.dart';
import '../../../models/note/note_status.dart';
import '../../../providers/notes/notes_provider.dart';
import '../../../providers/notifiers/notifiers.dart';
import '../../extensions/build_context_extension.dart';
import '../../preferences/preference_key.dart';
import '../authentication.dart';
import 'select.dart';

/// Toggles whether the [notes] are locked.
Future<bool> toggleLockNotes(
BuildContext context,
WidgetRef ref, {
required List<Note> notes,
bool requireAuthentication = false,
}) async {
if (requireAuthentication) {
final lockNotePreference = PreferenceKey.lockNote.preferenceOrDefault;
final anyLocked = notes.any((note) => note.locked);
Future<bool> toggleLockNotes(BuildContext context, WidgetRef ref, {required List<Note> notes}) async {
// If required, ask for authentication
final requiresAuthentication = notes.any((note) => note.requiresAuthentication);
if (requiresAuthentication) {
final authenticated = await authenticate(context, reason: context.l.lock_page_reason_action);

// If the lock note setting is enabled and a note was locked, then ask to authenticate
if (lockNotePreference && anyLocked) {
final bool authenticated = await LocalAuthentication().authenticate(
localizedReason: context.l.lock_page_reason_action,
);

if (!authenticated) {
return false;
}
if (!authenticated) {
return false;
}
}

Expand Down
28 changes: 28 additions & 0 deletions lib/common/dialogs/require_backup_dialog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';

import '../extensions/build_context_extension.dart';

/// Require backup dialog.
class RequireBackupDialog extends StatelessWidget {
/// Dialog to require the user to select a backup location.
const RequireBackupDialog({super.key});

@override
Widget build(BuildContext context) {
return AlertDialog.adaptive(
title: Text(context.l.dialog_require_backup_title),
content: SingleChildScrollView(child: Column(children: [Text(context.l.dialog_require_backup_description)])),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
child: Text(context.l.dialog_require_backup_ignore),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l.dialog_require_backup_select),
),
],
);
}
}
4 changes: 2 additions & 2 deletions lib/common/navigation/side_navigation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ class _SideNavigationState extends ConsumerState<SideNavigation> {
} else if (route == NavigationRoute.settings.name) {
index = labels.length + 4;
} else if (labels.isNotEmpty) {
labels.forEachIndexed((index, label) {
labels.forEachIndexed((i, label) {
if (route == NavigationRoute.getLabelRouteName(label)) {
index = index + 1;
index = i + 1;
}
});
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ enum AvailableSwipeAction {
return false;
case lock:
case unlock:
await toggleLockNotes(context, ref, notes: [note], requireAuthentication: true);
await toggleLockNotes(context, ref, notes: [note]);
return false;
case archive:
return await archiveNote(context, ref, note: note);
Expand Down
16 changes: 16 additions & 0 deletions lib/l10n/translations/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,22 @@
"@hint_label_name": {
"description": "Hint for the text field of the name of a tag."
},
"dialog_require_backup_title": "Select a backup location",
"@dialog_require_backup_title": {
"description": "Title of the dialog to select a backup location."
},
"dialog_require_backup_description": "As your notes are only stored locally on your device, any issue could lead to losing them all. Uninstalling the application would also delete all your notes.\n\nSelecting a backup location will ensure you can always retrieve your notes. For example, you could create a folder named \"Material Notes backups\" under your existing \"Documents\" folder.",
"@dialog_require_backup_description": {
"description": "Description of the dialog to select a backup location."
},
"dialog_require_backup_ignore": "Ignore",
"@dialog_require_backup_ignore": {
"description": "Button to ignore the dialog to select a backup location."
},
"dialog_require_backup_select": "Select",
"@dialog_require_backup_select": {
"description": "Button to select a backup location in the related dialog."
},
"dialog_export_encryption_password": "Password",
"@dialog_export_encryption_password": {
"description": "Hint for the password text field in the dialog to configure the encryption of an automatic or manual export."
Expand Down
11 changes: 10 additions & 1 deletion lib/models/label/label.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:isar_community/isar.dart';
import 'package:json_annotation/json_annotation.dart';

import '../../common/preferences/preference_key.dart';
import '../note/note.dart';

part 'label.g.dart';
Expand Down Expand Up @@ -46,6 +47,14 @@ class Label extends Equatable implements Comparable<Label> {
@JsonKey(defaultValue: false)
bool locked;

/// Whether the label requires authentication to be accessed.
@ignore
bool get requiresAuthentication {
final lockLabel = PreferenceKey.lockLabel.preferenceOrDefault;

return lockLabel && locked;
}

/// Whether the note is selected.
///
/// Excluded from JSON because it's only needed temporarily during multi-selection.
Expand All @@ -70,7 +79,7 @@ class Label extends Equatable implements Comparable<Label> {
}

/// Default constructor of a label.
Label({required this.name, required this.colorHex}) : visible = true, pinned = false, locked = true;
Label({required this.name, required this.colorHex}) : visible = true, pinned = false, locked = false;

/// Label from [json] data.
factory Label.fromJson(Map<String, dynamic> json) => _$LabelFromJson(json);
Expand Down
12 changes: 12 additions & 0 deletions lib/models/note/note.dart
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ sealed class Note implements Comparable<Note> {
@ignore
bool get hasLockedLabel => labels.any((label) => label.locked);

/// Whether the note requires authentication to be accessed.
@ignore
bool get requiresAuthentication {
final lockNote = PreferenceKey.lockNote.preferenceOrDefault;
final lockLabel = PreferenceKey.lockLabel.preferenceOrDefault;

final shouldLockNote = lockNote && locked;
final shouldLockLabel = lockLabel && hasLockedLabel;

return shouldLockNote || shouldLockLabel;
}

/// The [labels] as markdown.
@ignore
String get labelsAsMarkdown => '> ${labelsNamesVisibleSorted.join(', ')}';
Expand Down
Loading
Loading