Implement local vault service with encryption key management and integrate it into the app. Add settings screen for data management and enhance home screen with new features. Update database connection for encryption support and modify repository to use the new database structure. Improve UI elements across the application for better user experience.
This commit is contained in:
+194
-7
@@ -1,8 +1,17 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:notas/data/app_database.dart';
|
||||
import 'package:notas/data/local_vault_service.dart';
|
||||
import 'package:notas/data/note_repository.dart';
|
||||
import 'package:notas/platform/app_platform.dart';
|
||||
import 'package:notas/screens/home_screen.dart';
|
||||
import 'package:notas/theme/app_theme.dart';
|
||||
import 'package:notas/platform/window_state.dart';
|
||||
import 'package:notas/screens/home_screen.dart';
|
||||
import 'package:notas/screens/vault_access_screen.dart';
|
||||
import 'package:notas/theme/app_theme.dart';
|
||||
import 'package:notas/widgets/app_title_bar.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class NotesApp extends StatefulWidget {
|
||||
@@ -13,12 +22,22 @@ class NotesApp extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _NotesAppState extends State<NotesApp> with WindowListener {
|
||||
final LocalVaultService _vaultService = LocalVaultService.instance;
|
||||
final GlobalKey<ScaffoldMessengerState> _scaffoldMessengerKey =
|
||||
GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
AppDatabase? _database;
|
||||
NoteRepository? _repository;
|
||||
bool _isBootstrapping = true;
|
||||
bool _isUnlocking = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (isDesktop) {
|
||||
windowManager.addListener(this);
|
||||
}
|
||||
_bootstrapVault();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -26,9 +45,116 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
||||
if (isDesktop) {
|
||||
windowManager.removeListener(this);
|
||||
}
|
||||
_database?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _bootstrapVault() async {
|
||||
try {
|
||||
final String? encryptionKey = await _vaultService.readEncryptionKey();
|
||||
|
||||
if (encryptionKey != null) {
|
||||
await _openVault(encryptionKey);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isBootstrapping = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openVault(String encryptionKey) async {
|
||||
await _database?.close();
|
||||
|
||||
final AppDatabase database = AppDatabase(encryptionKey: encryptionKey);
|
||||
if (!mounted) {
|
||||
await database.close();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_database = database;
|
||||
_repository = NoteRepository(database: database);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _resetLocalVaultData() async {
|
||||
final AppDatabase? database = _database;
|
||||
|
||||
setState(() {
|
||||
_repository = null;
|
||||
_database = null;
|
||||
_isBootstrapping = true;
|
||||
});
|
||||
|
||||
await database?.close();
|
||||
|
||||
await _vaultService.clearEncryptionKey();
|
||||
|
||||
final Directory supportDir = await getApplicationSupportDirectory();
|
||||
final String dbPath = p.join(supportDir.path, 'notes.sqlite');
|
||||
final List<String> filesToDelete = <String>[
|
||||
dbPath,
|
||||
'$dbPath-wal',
|
||||
'$dbPath-shm',
|
||||
'$dbPath-journal',
|
||||
];
|
||||
|
||||
for (final String filePath in filesToDelete) {
|
||||
final File file = File(filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isBootstrapping = false;
|
||||
_isUnlocking = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _enterWithoutAccount() async {
|
||||
if (_isUnlocking) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isUnlocking = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final String encryptionKey = await _vaultService.createEncryptionKey();
|
||||
await _openVault(encryptionKey);
|
||||
} catch (error) {
|
||||
_scaffoldMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(content: Text('No se pudo crear el vault local: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isUnlocking = false;
|
||||
_isBootstrapping = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showAccountPlaceholder(String actionLabel) {
|
||||
_scaffoldMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'$actionLabel todavía no está conectado con la API. Usa "Entrar sin cuenta" para empezar en local.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveWindowSize() async {
|
||||
if (await windowManager.isFullScreen()) {
|
||||
return;
|
||||
@@ -42,6 +168,45 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
||||
await WindowStateStore.instance.saveWindowSize(currentSize);
|
||||
}
|
||||
|
||||
Widget _buildLoadingScreen() {
|
||||
return MaterialApp(
|
||||
title: 'Mis Notas',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.theme,
|
||||
home: const Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
AppTitleBar(),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Preparando el vault local...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppShell({required Widget home}) {
|
||||
return MaterialApp(
|
||||
title: 'Mis Notas',
|
||||
debugShowCheckedModeBanner: false,
|
||||
scaffoldMessengerKey: _scaffoldMessengerKey,
|
||||
theme: AppTheme.theme,
|
||||
home: home,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowResize() {
|
||||
_saveWindowSize();
|
||||
@@ -52,12 +217,34 @@ class _NotesAppState extends State<NotesApp> with WindowListener {
|
||||
_saveWindowSize();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Mis Notas',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.theme,
|
||||
home: const HomeScreen(),
|
||||
if (_isBootstrapping) {
|
||||
return _buildLoadingScreen();
|
||||
}
|
||||
|
||||
final NoteRepository? repository = _repository;
|
||||
|
||||
if (repository != null) {
|
||||
return _buildAppShell(
|
||||
home: HomeScreen(
|
||||
repository: repository,
|
||||
onDeleteAllData: _resetLocalVaultData,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildAppShell(
|
||||
home: VaultAccessScreen(
|
||||
isBusy: _isUnlocking,
|
||||
onCreateAccountPressed: (String email, String password) async {
|
||||
_showAccountPlaceholder('Crear cuenta');
|
||||
},
|
||||
onSignInPressed: (String email, String password) async {
|
||||
_showAccountPlaceholder('Iniciar sesión');
|
||||
},
|
||||
onContinueWithoutAccount: _enterWithoutAccount,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class Notes extends Table {
|
||||
|
||||
@DriftDatabase(tables: [Notes])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase() : super(_openConnection());
|
||||
AppDatabase({required String encryptionKey}) : super(_openConnection(encryptionKey));
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
@@ -94,11 +94,21 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
LazyDatabase _openConnection(String encryptionKey) {
|
||||
return LazyDatabase(() async {
|
||||
final Directory supportDir = await getApplicationSupportDirectory();
|
||||
final File file = File(p.join(supportDir.path, 'notes.sqlite'));
|
||||
|
||||
return NativeDatabase(file);
|
||||
return NativeDatabase(
|
||||
file,
|
||||
setup: (database) {
|
||||
final String escapedKey = encryptionKey.replaceAll("'", "''");
|
||||
|
||||
// sqlite3mc can emulate SQLCipher file format for compatibility.
|
||||
database.execute("PRAGMA cipher = 'sqlcipher'");
|
||||
database.execute('PRAGMA legacy = 4');
|
||||
database.execute("PRAGMA key = '$escapedKey'");
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class LocalVaultService {
|
||||
LocalVaultService._();
|
||||
|
||||
static final LocalVaultService instance = LocalVaultService._();
|
||||
|
||||
static const String _encryptionKeyStorageKey =
|
||||
'notes_local_encryption_key_v1';
|
||||
|
||||
final FlutterSecureStorage _secureStorage = FlutterSecureStorage();
|
||||
|
||||
String? _cachedEncryptionKey;
|
||||
|
||||
Future<String?> readEncryptionKey() async {
|
||||
final String? cachedKey = _cachedEncryptionKey;
|
||||
if (cachedKey != null) {
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
final String? storedKey = await _secureStorage.read(
|
||||
key: _encryptionKeyStorageKey,
|
||||
);
|
||||
|
||||
_cachedEncryptionKey = storedKey;
|
||||
return storedKey;
|
||||
}
|
||||
|
||||
Future<String> createEncryptionKey() async {
|
||||
final String encryptionKey = _generateEncryptionKey();
|
||||
|
||||
await _secureStorage.write(
|
||||
key: _encryptionKeyStorageKey,
|
||||
value: encryptionKey,
|
||||
);
|
||||
|
||||
_cachedEncryptionKey = encryptionKey;
|
||||
return encryptionKey;
|
||||
}
|
||||
|
||||
Future<void> clearEncryptionKey() async {
|
||||
await _secureStorage.delete(key: _encryptionKeyStorageKey);
|
||||
_cachedEncryptionKey = null;
|
||||
}
|
||||
|
||||
String _generateEncryptionKey() {
|
||||
final Random random = Random.secure();
|
||||
final List<int> bytes = List<int>.generate(32, (_) => random.nextInt(256));
|
||||
|
||||
return bytes
|
||||
.map((int byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,7 @@ import 'package:notas/data/app_database.dart';
|
||||
import 'package:notas/models/note.dart';
|
||||
|
||||
class NoteRepository {
|
||||
NoteRepository({AppDatabase? database}) : _database = database ?? _sharedDatabase;
|
||||
|
||||
static final AppDatabase _sharedDatabase = AppDatabase();
|
||||
NoteRepository({required AppDatabase database}) : _database = database;
|
||||
|
||||
final AppDatabase _database;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:notas/models/note.dart';
|
||||
import 'package:notas/screens/note_editor_screen.dart';
|
||||
import 'package:notas/widgets/app_title_bar.dart';
|
||||
import 'package:notas/widgets/menu_drawer.dart';
|
||||
import 'package:notas/screens/settings_screen.dart';
|
||||
import 'package:notas/widgets/note_card.dart';
|
||||
import 'package:notas/widgets/search_app_bar.dart';
|
||||
|
||||
@@ -18,14 +19,20 @@ import 'package:notas/widgets/search_app_bar.dart';
|
||||
// - Drag & drop reordering (updates `index` in the database)
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
const HomeScreen({
|
||||
super.key,
|
||||
required this.repository,
|
||||
required this.onDeleteAllData,
|
||||
});
|
||||
|
||||
final NoteRepository repository;
|
||||
final Future<void> Function() onDeleteAllData;
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
final NoteRepository _repository = NoteRepository();
|
||||
List<Note> _notes = <Note>[];
|
||||
String _searchQuery = '';
|
||||
bool _isLoading = true;
|
||||
@@ -39,7 +46,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadNotes() async {
|
||||
final List<Note> storedNotes = await _repository.loadNotes();
|
||||
final List<Note> storedNotes = await widget.repository.loadNotes();
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
@@ -59,7 +66,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
if (result is Note) {
|
||||
final Note createdNote = await _repository.createNote(result);
|
||||
final Note createdNote = await widget.repository.createNote(result);
|
||||
final List<Note> updatedNotes = _normalizeNotes(<Note>[createdNote, ..._notes]);
|
||||
|
||||
if (!mounted) {
|
||||
@@ -73,7 +80,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
|
||||
Future<void> _deleteNote(Note note) async {
|
||||
await _repository.deleteNote(note);
|
||||
await widget.repository.deleteNote(note);
|
||||
|
||||
final List<Note> updatedNotes = _normalizeNotes(
|
||||
_notes.where((Note item) => item.id != note.id).toList(),
|
||||
@@ -97,7 +104,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final Note movedNote = updatedNotes.removeAt(oldIndex);
|
||||
updatedNotes.insert(newIndex, movedNote);
|
||||
|
||||
await _repository.moveNote(movedNote, newIndex);
|
||||
await widget.repository.moveNote(movedNote, newIndex);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
@@ -123,7 +130,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
if (result is Note) {
|
||||
final int noteIndex = _notes.indexWhere((Note item) => item == note);
|
||||
if (noteIndex != -1) {
|
||||
final Note savedNote = await _repository.updateNote(result);
|
||||
final Note savedNote = await widget.repository.updateNote(result);
|
||||
final List<Note> updatedNotes = [..._notes];
|
||||
updatedNotes[noteIndex] = savedNote;
|
||||
|
||||
@@ -293,8 +300,20 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Color(0xFF191A1D),
|
||||
Color(0xFF222326),
|
||||
Color(0xFF101114),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const AppTitleBar(),
|
||||
SearchAppBar(
|
||||
@@ -353,6 +372,16 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
setState(() {
|
||||
_isMenuOpen = false;
|
||||
});
|
||||
|
||||
if (item == 'settings') {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SettingsScreen(
|
||||
onDeleteAllData: widget.onDeleteAllData,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -361,7 +390,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _openNoteComposer,
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:notas/widgets/app_title_bar.dart';
|
||||
import 'package:notas/widgets/menu_drawer.dart';
|
||||
import 'package:notas/widgets/search_app_bar.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({
|
||||
super.key,
|
||||
required this.onDeleteAllData,
|
||||
});
|
||||
|
||||
final Future<void> Function() onDeleteAllData;
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _isBusy = false;
|
||||
bool _isMenuOpen = false;
|
||||
final GlobalKey _headerKey = GlobalKey();
|
||||
double _menuTopInset = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_updateMenuTopInset();
|
||||
});
|
||||
}
|
||||
|
||||
void _updateMenuTopInset() {
|
||||
final BuildContext? headerContext = _headerKey.currentContext;
|
||||
if (headerContext == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final RenderObject? renderObject = headerContext.findRenderObject();
|
||||
if (renderObject is! RenderBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
final double newInset = renderObject.size.height;
|
||||
if ((newInset - _menuTopInset).abs() < 0.5) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_menuTopInset = newInset;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _confirmAndDeleteAll() async {
|
||||
final bool? confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Borrar todos los datos'),
|
||||
content: const Text('¿Estás seguro? Esta acción eliminará la base de datos local y la clave de cifrado. No se podrá recuperar.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancelar')),
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(true), child: const Text('Borrar', style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
setState(() {
|
||||
_isBusy = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await widget.onDeleteAllData();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Todos los datos locales han sido eliminados.')),
|
||||
);
|
||||
|
||||
Navigator.of(context).pop();
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error al borrar los datos: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isBusy = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMenuItemTapped(String item) {
|
||||
setState(() {
|
||||
_isMenuOpen = false;
|
||||
});
|
||||
|
||||
if (item == 'all_notes') {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_updateMenuTopInset();
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Color(0xFF191A1D),
|
||||
Color(0xFF222326),
|
||||
Color(0xFF101114),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Column(
|
||||
key: _headerKey,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AppTitleBar(),
|
||||
SearchAppBar(
|
||||
onMenuPressed: () {
|
||||
setState(() {
|
||||
_isMenuOpen = !_isMenuOpen;
|
||||
});
|
||||
},
|
||||
showSearch: false,
|
||||
titleText: 'Configuración',
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent),
|
||||
onPressed: _isBusy ? null : _confirmAndDeleteAll,
|
||||
icon: _isBusy ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.delete_forever),
|
||||
label: const Text('Borrar todos los datos'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Esto cerrará el vault actual y eliminará la base de datos local junto con la clave de cifrado.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned.fill(
|
||||
top: _menuTopInset,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_isMenuOpen,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
opacity: _isMenuOpen ? 0.5 : 0.0,
|
||||
curve: Curves.easeOutCubic,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_isMenuOpen = false;
|
||||
});
|
||||
},
|
||||
child: Container(color: Colors.black),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: _isMenuOpen ? 0 : -280,
|
||||
top: _menuTopInset,
|
||||
bottom: 0,
|
||||
width: 280,
|
||||
child: Material(
|
||||
color: const Color.fromRGBO(24, 25, 26, 1),
|
||||
elevation: 8,
|
||||
child: MenuDrawer(onMenuItemTapped: _handleMenuItemTapped),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:notas/widgets/app_title_bar.dart';
|
||||
|
||||
class VaultAccessScreen extends StatefulWidget {
|
||||
const VaultAccessScreen({
|
||||
super.key,
|
||||
required this.isBusy,
|
||||
required this.onCreateAccountPressed,
|
||||
required this.onSignInPressed,
|
||||
required this.onContinueWithoutAccount,
|
||||
});
|
||||
|
||||
final bool isBusy;
|
||||
final Future<void> Function(String email, String password) onCreateAccountPressed;
|
||||
final Future<void> Function(String email, String password) onSignInPressed;
|
||||
final Future<void> Function() onContinueWithoutAccount;
|
||||
|
||||
@override
|
||||
State<VaultAccessScreen> createState() => _VaultAccessScreenState();
|
||||
}
|
||||
|
||||
class _VaultAccessScreenState extends State<VaultAccessScreen> {
|
||||
final TextEditingController _emailController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleCreateAccount() async {
|
||||
await widget.onCreateAccountPressed(
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleSignIn() async {
|
||||
await widget.onSignInPressed(
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Color(0xFF191A1D),
|
||||
Color(0xFF222326),
|
||||
Color(0xFF101114),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const AppTitleBar(),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 460),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1D1E20),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.35),
|
||||
blurRadius: 30,
|
||||
offset: const Offset(0, 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.lock_outline,
|
||||
color: Colors.amber,
|
||||
size: 44,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Mis Notas',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Tus notas se guardan cifradas en este dispositivo. La cuenta y la sincronización vendrán después.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
TextField(
|
||||
controller: _emailController,
|
||||
enabled: !widget.isBusy,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Email',
|
||||
labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.05),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(color: Colors.amber, width: 1.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
enabled: !widget.isBusy,
|
||||
obscureText: true,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Contraseña',
|
||||
labelStyle: TextStyle(color: Colors.white.withValues(alpha: 0.7)),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.05),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.12)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(color: Colors.amber, width: 1.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
FilledButton(
|
||||
onPressed: widget.isBusy ? null : _handleCreateAccount,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: widget.isBusy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Crear cuenta'),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
OutlinedButton(
|
||||
onPressed: widget.isBusy ? null : _handleSignIn,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
side: const BorderSide(color: Colors.white24),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Iniciar sesión'),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
TextButton(
|
||||
onPressed: widget.isBusy ? null : widget.onContinueWithoutAccount,
|
||||
child: const Text('Entrar sin cuenta'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class MenuDrawer extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color.fromRGBO(31, 32, 33, 1),
|
||||
color: Color.fromARGB(255, 30, 31, 35),
|
||||
border: Border(
|
||||
right: BorderSide(color: Colors.white12, width: 0.5),
|
||||
),
|
||||
|
||||
@@ -6,11 +6,15 @@ class SearchAppBar extends StatefulWidget {
|
||||
this.onMenuPressed,
|
||||
this.onSearchChanged,
|
||||
this.searchHint = 'Buscar notas...',
|
||||
this.showSearch = true,
|
||||
this.titleText,
|
||||
});
|
||||
|
||||
final VoidCallback? onMenuPressed;
|
||||
final ValueChanged<String>? onSearchChanged;
|
||||
final String searchHint;
|
||||
final bool showSearch;
|
||||
final String? titleText;
|
||||
|
||||
@override
|
||||
State<SearchAppBar> createState() => _SearchAppBarState();
|
||||
@@ -35,7 +39,7 @@ class _SearchAppBarState extends State<SearchAppBar> {
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color.fromRGBO(31, 32, 33, 1),
|
||||
color: Colors.transparent,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
@@ -55,75 +59,86 @@ class _SearchAppBarState extends State<SearchAppBar> {
|
||||
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Search input (centered with max width)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: widget.onSearchChanged,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||
cursorColor: Colors.white70,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.searchHint,
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: Colors.white70,
|
||||
size: 18,
|
||||
),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(
|
||||
Icons.clear,
|
||||
color: Colors.white70,
|
||||
size: 18,
|
||||
child: widget.showSearch
|
||||
? Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: widget.onSearchChanged,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||
cursorColor: Colors.white70,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.searchHint,
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: Colors.white70,
|
||||
size: 18,
|
||||
),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(
|
||||
Icons.clear,
|
||||
color: Colors.white70,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
widget.onSearchChanged?.call('');
|
||||
},
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
width: 0.5,
|
||||
),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
widget.onSearchChanged?.call('');
|
||||
},
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
width: 0.5,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
width: 0.5,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.white.withValues(alpha: 0.4),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.05),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
width: 0.5,
|
||||
)
|
||||
: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
widget.titleText ?? '',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.white.withValues(alpha: 0.4),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.05),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
|
||||
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_linux
|
||||
screen_retriever_linux
|
||||
window_manager
|
||||
)
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import flutter_secure_storage_darwin
|
||||
import screen_retriever_macos
|
||||
import shared_preferences_foundation
|
||||
import window_manager
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
||||
|
||||
+65
-1
@@ -209,6 +209,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
ffi_leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi_leak_tracker
|
||||
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -238,6 +246,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "6848263f9744072d0977347c383fb8b57d9780319a6bf5238b5a2866a029de62"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.2.0"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_darwin
|
||||
sha256: "67cd1ff671add31dc13e45194398187a04bb63804b37fa47866afae296d73fcb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: "8f42f359f187a94dce7a3ab2ec5903d013dddfc7127078ebab19fa244c3840e8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.1"
|
||||
flutter_staggered_grid_view:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -678,7 +734,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
|
||||
@@ -797,6 +853,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.0"
|
||||
window_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -41,6 +41,8 @@ dependencies:
|
||||
shared_preferences: ^2.3.2
|
||||
window_manager: ^0.5.1
|
||||
intl: ^0.19.0
|
||||
flutter_secure_storage: ^10.2.0
|
||||
sqlite3: ^3.3.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -66,6 +68,11 @@ flutter:
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
hooks:
|
||||
user_defines:
|
||||
sqlite3:
|
||||
source: sqlite3mc
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
|
||||
WindowManagerPluginRegisterWithRegistrar(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_windows
|
||||
screen_retriever_windows
|
||||
window_manager
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user