feat: Add color and icon properties to categories, enhance category management in UI
This commit is contained in:
@@ -15,6 +15,8 @@ class Categories extends Table {
|
||||
integer().named('server_version').withDefault(const Constant(0))();
|
||||
BoolColumn get isDeleted =>
|
||||
boolean().named('is_deleted').withDefault(const Constant(false))();
|
||||
IntColumn get colorValue => integer().nullable().named('color_value')();
|
||||
IntColumn get iconCodePoint => integer().nullable().named('icon_code_point')();
|
||||
BoolColumn get isDirty =>
|
||||
boolean().named('is_dirty').withDefault(const Constant(true))();
|
||||
DateTimeColumn get updatedAt => dateTime().named('updated_at')();
|
||||
@@ -46,7 +48,7 @@ class Notes extends Table {
|
||||
@DriftDatabase(tables: [Notes, Categories])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
@override
|
||||
int get schemaVersion => 2;
|
||||
int get schemaVersion => 3;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -61,6 +63,12 @@ class AppDatabase extends _$AppDatabase {
|
||||
await customStatement('UPDATE notes SET is_dirty = 0');
|
||||
await customStatement('UPDATE categories SET is_dirty = 0');
|
||||
}
|
||||
if (from < 3) {
|
||||
await migrator.addColumn(categories, categories.colorValue);
|
||||
await migrator.addColumn(categories, categories.iconCodePoint);
|
||||
await customStatement('UPDATE categories SET color_value = NULL');
|
||||
await customStatement('UPDATE categories SET icon_code_point = NULL');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -682,6 +682,28 @@ class $CategoriesTable extends Categories
|
||||
),
|
||||
defaultValue: const Constant(true),
|
||||
);
|
||||
static const VerificationMeta _colorValueMeta = const VerificationMeta(
|
||||
'colorValue',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<int> colorValue = GeneratedColumn<int>(
|
||||
'color_value',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _iconCodePointMeta = const VerificationMeta(
|
||||
'iconCodePoint',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<int> iconCodePoint = GeneratedColumn<int>(
|
||||
'icon_code_point',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.int,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const VerificationMeta _updatedAtMeta = const VerificationMeta(
|
||||
'updatedAt',
|
||||
);
|
||||
@@ -700,6 +722,8 @@ class $CategoriesTable extends Categories
|
||||
serverVersion,
|
||||
isDeleted,
|
||||
isDirty,
|
||||
colorValue,
|
||||
iconCodePoint,
|
||||
updatedAt,
|
||||
];
|
||||
@override
|
||||
@@ -751,6 +775,21 @@ class $CategoriesTable extends Categories
|
||||
isDirty.isAcceptableOrUnknown(data['is_dirty']!, _isDirtyMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('color_value')) {
|
||||
context.handle(
|
||||
_colorValueMeta,
|
||||
colorValue.isAcceptableOrUnknown(data['color_value']!, _colorValueMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('icon_code_point')) {
|
||||
context.handle(
|
||||
_iconCodePointMeta,
|
||||
iconCodePoint.isAcceptableOrUnknown(
|
||||
data['icon_code_point']!,
|
||||
_iconCodePointMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('updated_at')) {
|
||||
context.handle(
|
||||
_updatedAtMeta,
|
||||
@@ -788,6 +827,14 @@ class $CategoriesTable extends Categories
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_dirty'],
|
||||
)!,
|
||||
colorValue: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}color_value'],
|
||||
),
|
||||
iconCodePoint: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}icon_code_point'],
|
||||
),
|
||||
updatedAt: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}updated_at'],
|
||||
@@ -807,6 +854,8 @@ class DbCategory extends DataClass implements Insertable<DbCategory> {
|
||||
final int serverVersion;
|
||||
final bool isDeleted;
|
||||
final bool isDirty;
|
||||
final int? colorValue;
|
||||
final int? iconCodePoint;
|
||||
final DateTime updatedAt;
|
||||
const DbCategory({
|
||||
required this.id,
|
||||
@@ -814,6 +863,8 @@ class DbCategory extends DataClass implements Insertable<DbCategory> {
|
||||
required this.serverVersion,
|
||||
required this.isDeleted,
|
||||
required this.isDirty,
|
||||
this.colorValue,
|
||||
this.iconCodePoint,
|
||||
required this.updatedAt,
|
||||
});
|
||||
@override
|
||||
@@ -824,6 +875,12 @@ class DbCategory extends DataClass implements Insertable<DbCategory> {
|
||||
map['server_version'] = Variable<int>(serverVersion);
|
||||
map['is_deleted'] = Variable<bool>(isDeleted);
|
||||
map['is_dirty'] = Variable<bool>(isDirty);
|
||||
if (!nullToAbsent || colorValue != null) {
|
||||
map['color_value'] = Variable<int>(colorValue);
|
||||
}
|
||||
if (!nullToAbsent || iconCodePoint != null) {
|
||||
map['icon_code_point'] = Variable<int>(iconCodePoint);
|
||||
}
|
||||
map['updated_at'] = Variable<DateTime>(updatedAt);
|
||||
return map;
|
||||
}
|
||||
@@ -835,6 +892,12 @@ class DbCategory extends DataClass implements Insertable<DbCategory> {
|
||||
serverVersion: Value(serverVersion),
|
||||
isDeleted: Value(isDeleted),
|
||||
isDirty: Value(isDirty),
|
||||
colorValue: colorValue == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value<int?>(colorValue),
|
||||
iconCodePoint: iconCodePoint == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value<int?>(iconCodePoint),
|
||||
updatedAt: Value(updatedAt),
|
||||
);
|
||||
}
|
||||
@@ -850,6 +913,8 @@ class DbCategory extends DataClass implements Insertable<DbCategory> {
|
||||
serverVersion: serializer.fromJson<int>(json['serverVersion']),
|
||||
isDeleted: serializer.fromJson<bool>(json['isDeleted']),
|
||||
isDirty: serializer.fromJson<bool>(json['isDirty']),
|
||||
colorValue: serializer.fromJson<int?>(json['colorValue']),
|
||||
iconCodePoint: serializer.fromJson<int?>(json['iconCodePoint']),
|
||||
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
|
||||
);
|
||||
}
|
||||
@@ -862,6 +927,8 @@ class DbCategory extends DataClass implements Insertable<DbCategory> {
|
||||
'serverVersion': serializer.toJson<int>(serverVersion),
|
||||
'isDeleted': serializer.toJson<bool>(isDeleted),
|
||||
'isDirty': serializer.toJson<bool>(isDirty),
|
||||
'colorValue': serializer.toJson<int?>(colorValue),
|
||||
'iconCodePoint': serializer.toJson<int?>(iconCodePoint),
|
||||
'updatedAt': serializer.toJson<DateTime>(updatedAt),
|
||||
};
|
||||
}
|
||||
@@ -872,6 +939,8 @@ class DbCategory extends DataClass implements Insertable<DbCategory> {
|
||||
int? serverVersion,
|
||||
bool? isDeleted,
|
||||
bool? isDirty,
|
||||
int? colorValue,
|
||||
int? iconCodePoint,
|
||||
DateTime? updatedAt,
|
||||
}) => DbCategory(
|
||||
id: id ?? this.id,
|
||||
@@ -879,6 +948,8 @@ class DbCategory extends DataClass implements Insertable<DbCategory> {
|
||||
serverVersion: serverVersion ?? this.serverVersion,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
isDirty: isDirty ?? this.isDirty,
|
||||
colorValue: colorValue ?? this.colorValue,
|
||||
iconCodePoint: iconCodePoint ?? this.iconCodePoint,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
DbCategory copyWithCompanion(CategoriesCompanion data) {
|
||||
@@ -892,6 +963,12 @@ class DbCategory extends DataClass implements Insertable<DbCategory> {
|
||||
: this.serverVersion,
|
||||
isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted,
|
||||
isDirty: data.isDirty.present ? data.isDirty.value : this.isDirty,
|
||||
colorValue: data.colorValue.present
|
||||
? data.colorValue.value
|
||||
: this.colorValue,
|
||||
iconCodePoint: data.iconCodePoint.present
|
||||
? data.iconCodePoint.value
|
||||
: this.iconCodePoint,
|
||||
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
|
||||
);
|
||||
}
|
||||
@@ -938,12 +1015,16 @@ class CategoriesCompanion extends UpdateCompanion<DbCategory> {
|
||||
final Value<bool> isDirty;
|
||||
final Value<DateTime> updatedAt;
|
||||
final Value<int> rowid;
|
||||
final Value<int?> colorValue;
|
||||
final Value<int?> iconCodePoint;
|
||||
const CategoriesCompanion({
|
||||
this.id = const Value.absent(),
|
||||
this.encryptedName = const Value.absent(),
|
||||
this.serverVersion = const Value.absent(),
|
||||
this.isDeleted = const Value.absent(),
|
||||
this.isDirty = const Value.absent(),
|
||||
this.colorValue = const Value.absent(),
|
||||
this.iconCodePoint = const Value.absent(),
|
||||
this.updatedAt = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
});
|
||||
@@ -953,6 +1034,8 @@ class CategoriesCompanion extends UpdateCompanion<DbCategory> {
|
||||
this.serverVersion = const Value.absent(),
|
||||
this.isDeleted = const Value.absent(),
|
||||
this.isDirty = const Value.absent(),
|
||||
this.colorValue = const Value.absent(),
|
||||
this.iconCodePoint = const Value.absent(),
|
||||
required DateTime updatedAt,
|
||||
this.rowid = const Value.absent(),
|
||||
}) : id = Value(id),
|
||||
@@ -964,6 +1047,8 @@ class CategoriesCompanion extends UpdateCompanion<DbCategory> {
|
||||
Expression<int>? serverVersion,
|
||||
Expression<bool>? isDeleted,
|
||||
Expression<bool>? isDirty,
|
||||
Expression<int>? colorValue,
|
||||
Expression<int>? iconCodePoint,
|
||||
Expression<DateTime>? updatedAt,
|
||||
Expression<int>? rowid,
|
||||
}) {
|
||||
@@ -973,6 +1058,8 @@ class CategoriesCompanion extends UpdateCompanion<DbCategory> {
|
||||
if (serverVersion != null) 'server_version': serverVersion,
|
||||
if (isDeleted != null) 'is_deleted': isDeleted,
|
||||
if (isDirty != null) 'is_dirty': isDirty,
|
||||
if (colorValue != null) 'color_value': colorValue,
|
||||
if (iconCodePoint != null) 'icon_code_point': iconCodePoint,
|
||||
if (updatedAt != null) 'updated_at': updatedAt,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
});
|
||||
@@ -984,6 +1071,8 @@ class CategoriesCompanion extends UpdateCompanion<DbCategory> {
|
||||
Value<int>? serverVersion,
|
||||
Value<bool>? isDeleted,
|
||||
Value<bool>? isDirty,
|
||||
Value<int>? colorValue,
|
||||
Value<int>? iconCodePoint,
|
||||
Value<DateTime>? updatedAt,
|
||||
Value<int>? rowid,
|
||||
}) {
|
||||
@@ -993,6 +1082,8 @@ class CategoriesCompanion extends UpdateCompanion<DbCategory> {
|
||||
serverVersion: serverVersion ?? this.serverVersion,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
isDirty: isDirty ?? this.isDirty,
|
||||
colorValue: colorValue ?? this.colorValue,
|
||||
iconCodePoint: iconCodePoint ?? this.iconCodePoint,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
rowid: rowid ?? this.rowid,
|
||||
);
|
||||
@@ -1016,6 +1107,12 @@ class CategoriesCompanion extends UpdateCompanion<DbCategory> {
|
||||
if (isDirty.present) {
|
||||
map['is_dirty'] = Variable<bool>(isDirty.value);
|
||||
}
|
||||
if (colorValue.present) {
|
||||
map['color_value'] = Variable<int>(colorValue.value);
|
||||
}
|
||||
if (iconCodePoint.present) {
|
||||
map['icon_code_point'] = Variable<int>(iconCodePoint.value);
|
||||
}
|
||||
if (updatedAt.present) {
|
||||
map['updated_at'] = Variable<DateTime>(updatedAt.value);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:notas/models/category.dart';
|
||||
|
||||
import 'package:notas/data/note_encryption.dart';
|
||||
import 'package:notas/widgets/sync_status.dart';
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
|
||||
class NoteRepository {
|
||||
NoteRepository({
|
||||
@@ -34,6 +35,56 @@ class NoteRepository {
|
||||
return _loadDeletedNotesFromDatabase();
|
||||
}
|
||||
|
||||
Future<List<Category>> loadCategories() async {
|
||||
final List<DbCategory> dbCategories = await _database.getAllCategories();
|
||||
final List<Category> categories = [];
|
||||
for (final DbCategory row in dbCategories) {
|
||||
try {
|
||||
final String decryptedName = await NoteEncryption.decryptNote(
|
||||
row.encryptedName,
|
||||
_masterKey,
|
||||
);
|
||||
categories.add(
|
||||
Category(
|
||||
id: row.id,
|
||||
name: decryptedName,
|
||||
serverVersion: row.serverVersion,
|
||||
isDeleted: row.isDeleted,
|
||||
updatedAt: row.updatedAt,
|
||||
isDirty: row.isDirty,
|
||||
colorValue: row.colorValue,
|
||||
iconCodePoint: row.iconCodePoint,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error al desencriptar categoría: $e');
|
||||
}
|
||||
}
|
||||
return categories;
|
||||
}
|
||||
|
||||
Future<void> createCategory(Category category) async {
|
||||
debugPrint('createCategory called with: ${category.name}');
|
||||
final String encryptedName = await NoteEncryption.encryptNote(
|
||||
category.name,
|
||||
_masterKey,
|
||||
);
|
||||
debugPrint('Category name encrypted');
|
||||
await _database.upsertCategory(
|
||||
CategoriesCompanion.insert(
|
||||
id: category.id,
|
||||
encryptedName: encryptedName,
|
||||
updatedAt: category.updatedAt,
|
||||
serverVersion: const Value(0),
|
||||
isDeleted: const Value(false),
|
||||
isDirty: const Value(true),
|
||||
colorValue: Value<int?>(category.colorValue),
|
||||
iconCodePoint: Value<int?>(category.iconCodePoint),
|
||||
),
|
||||
);
|
||||
debugPrint('Category inserted to database');
|
||||
}
|
||||
|
||||
Future<Note> createNote(Note note) async {
|
||||
await _database.insertNoteAtTop(
|
||||
NotesCompanion.insert(
|
||||
@@ -60,7 +111,7 @@ class NoteRepository {
|
||||
|
||||
final DbNote row =
|
||||
existingNote ??
|
||||
(throw ArgumentError('Note not found for id ${note.id}.'));
|
||||
(throw ArgumentError('Note not found for id ${note.id}.'));
|
||||
|
||||
await _database.updateNoteRow(
|
||||
DbNote(
|
||||
@@ -92,7 +143,7 @@ class NoteRepository {
|
||||
|
||||
final DbNote row =
|
||||
existingNote ??
|
||||
(throw ArgumentError('Note not found for id ${note.id}.'));
|
||||
(throw ArgumentError('Note not found for id ${note.id}.'));
|
||||
|
||||
if (row.isDeleted || note.isDeleted || note.isPermanentlyDeleted) {
|
||||
await _database.permanentlyDeleteNote(row.id);
|
||||
@@ -111,7 +162,7 @@ class NoteRepository {
|
||||
|
||||
final DbNote row =
|
||||
existingNote ??
|
||||
(throw ArgumentError('Note not found for id ${note.id}.'));
|
||||
(throw ArgumentError('Note not found for id ${note.id}.'));
|
||||
|
||||
await _database.moveNote(
|
||||
id: row.id,
|
||||
@@ -212,10 +263,7 @@ class NoteRepository {
|
||||
details.add('StackTrace: ${stackTrace.toString()}');
|
||||
}
|
||||
|
||||
return {
|
||||
'error': true,
|
||||
'message': details.join('\n\n'),
|
||||
};
|
||||
return {'error': true, 'message': details.join('\n\n')};
|
||||
}
|
||||
|
||||
final SyncResponse response = syncResult['data'] as SyncResponse;
|
||||
@@ -249,10 +297,7 @@ class NoteRepository {
|
||||
'categoriesCount': response.changes.categories.length,
|
||||
};
|
||||
} catch (e, st) {
|
||||
return {
|
||||
'error': true,
|
||||
'message': '$e\n\nStackTrace: $st',
|
||||
};
|
||||
return {'error': true, 'message': '$e\n\nStackTrace: $st'};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,21 +308,18 @@ class NoteRepository {
|
||||
// Apply categories from server
|
||||
for (final SyncCategoryResponse catResponse
|
||||
in response.changes.categories) {
|
||||
final String categoryName =
|
||||
catResponse.isDeleted || catResponse.encryptedName.isEmpty
|
||||
? ''
|
||||
: await NoteEncryption.decryptNote(
|
||||
catResponse.encryptedName,
|
||||
_masterKey,
|
||||
);
|
||||
|
||||
// Store the encrypted blob received from the server directly in the DB.
|
||||
// Decryption is only performed when loading categories for display.
|
||||
final String encryptedBlob = catResponse.encryptedName;
|
||||
|
||||
await _database.upsertCategory(
|
||||
CategoriesCompanion(
|
||||
id: Value(catResponse.id),
|
||||
encryptedName: Value(categoryName),
|
||||
encryptedName: Value(encryptedBlob),
|
||||
serverVersion: Value(catResponse.serverVersion),
|
||||
isDeleted: Value(catResponse.isDeleted),
|
||||
colorValue: Value<int?>(catResponse.colorValue),
|
||||
iconCodePoint: Value<int?>(catResponse.iconCodePoint),
|
||||
updatedAt: Value(catResponse.updatedAt),
|
||||
isDirty: const Value(false),
|
||||
),
|
||||
@@ -439,9 +481,10 @@ Future<List<SyncCategoryPayload>> _encryptCategories(
|
||||
final List<SyncCategoryPayload> payloads = [];
|
||||
|
||||
for (final DbCategory row in categories) {
|
||||
final String encryptedName = row.encryptedName.isEmpty
|
||||
? ''
|
||||
: await NoteEncryption.encryptNote(row.encryptedName, masterKey);
|
||||
// The DB already stores the encrypted name blob in `encryptedName`.
|
||||
// Use it directly when building the sync payload and preserve
|
||||
// color/icon values from the DB row so they are sent to the server.
|
||||
final String encryptedName = row.encryptedName;
|
||||
|
||||
payloads.add(
|
||||
SyncCategoryPayload.fromCategory(
|
||||
@@ -452,6 +495,8 @@ Future<List<SyncCategoryPayload>> _encryptCategories(
|
||||
isDeleted: row.isDeleted,
|
||||
updatedAt: row.updatedAt,
|
||||
isDirty: row.isDirty,
|
||||
colorValue: row.colorValue,
|
||||
iconCodePoint: row.iconCodePoint,
|
||||
),
|
||||
encryptedName: encryptedName,
|
||||
),
|
||||
|
||||
@@ -95,6 +95,8 @@ class SyncCategoryPayload {
|
||||
required this.encryptedName,
|
||||
required this.serverVersion,
|
||||
this.isDeleted = false,
|
||||
this.colorValue,
|
||||
this.iconCodePoint,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
@@ -102,6 +104,8 @@ class SyncCategoryPayload {
|
||||
final String encryptedName;
|
||||
final int serverVersion;
|
||||
final bool isDeleted;
|
||||
final int? colorValue;
|
||||
final int? iconCodePoint;
|
||||
final DateTime updatedAt;
|
||||
|
||||
factory SyncCategoryPayload.fromCategory(
|
||||
@@ -113,6 +117,8 @@ class SyncCategoryPayload {
|
||||
encryptedName: encryptedName,
|
||||
serverVersion: category.serverVersion,
|
||||
isDeleted: category.isDeleted,
|
||||
colorValue: category.colorValue,
|
||||
iconCodePoint: category.iconCodePoint,
|
||||
updatedAt: category.updatedAt,
|
||||
);
|
||||
}
|
||||
@@ -123,6 +129,8 @@ class SyncCategoryPayload {
|
||||
'encrypted_name': encryptedName,
|
||||
'serverVersion': serverVersion,
|
||||
'isDeleted': isDeleted,
|
||||
if (colorValue != null) 'colorValue': colorValue!.toSigned(32),
|
||||
if (iconCodePoint != null) 'iconCodePoint': iconCodePoint,
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
@@ -213,12 +221,16 @@ class SyncCategoryResponse {
|
||||
required this.encryptedName,
|
||||
required this.serverVersion,
|
||||
this.isDeleted = false,
|
||||
this.colorValue,
|
||||
this.iconCodePoint,
|
||||
required this.updatedAt,
|
||||
});
|
||||
final String id;
|
||||
final String encryptedName;
|
||||
final int serverVersion;
|
||||
final bool isDeleted;
|
||||
final int? colorValue;
|
||||
final int? iconCodePoint;
|
||||
final DateTime updatedAt;
|
||||
|
||||
factory SyncCategoryResponse.fromJson(Map<String, dynamic> json) {
|
||||
@@ -227,6 +239,12 @@ class SyncCategoryResponse {
|
||||
encryptedName: _readStringValue(json['encrypted_name']),
|
||||
serverVersion: _readIntValue(json['serverVersion']),
|
||||
isDeleted: json['isDeleted'] as bool? ?? false,
|
||||
colorValue: json['colorValue'] == null
|
||||
? null
|
||||
: _readIntValue(json['colorValue']),
|
||||
iconCodePoint: json['iconCodePoint'] == null
|
||||
? null
|
||||
: _readIntValue(json['iconCodePoint']),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
);
|
||||
}
|
||||
@@ -237,6 +255,8 @@ class SyncCategoryResponse {
|
||||
name: name,
|
||||
serverVersion: serverVersion,
|
||||
isDeleted: isDeleted,
|
||||
colorValue: colorValue,
|
||||
iconCodePoint: iconCodePoint,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user