Add Windows runner files for high DPI support and console output

- Created runner.exe.manifest to enable DPI awareness and dark mode support.
- Implemented utility functions in utils.cpp and utils.h for console creation and command line argument handling.
- Developed Win32Window class in win32_window.cpp and win32_window.h to manage high DPI-aware windows, including theme updates and message handling.
This commit is contained in:
2026-05-13 12:03:40 +02:00
commit 96f8f95924
107 changed files with 6568 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
import 'package:flutter/material.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:window_manager/window_manager.dart';
class NotesApp extends StatefulWidget {
const NotesApp({super.key});
@override
State<NotesApp> createState() => _NotesAppState();
}
class _NotesAppState extends State<NotesApp> with WindowListener {
@override
void initState() {
super.initState();
if (isDesktop) {
windowManager.addListener(this);
}
}
@override
void dispose() {
if (isDesktop) {
windowManager.removeListener(this);
}
super.dispose();
}
Future<void> _saveWindowSize() async {
if (await windowManager.isFullScreen()) {
return;
}
if (await windowManager.isMaximized()) {
return;
}
final Size currentSize = await windowManager.getSize();
await WindowStateStore.instance.saveWindowSize(currentSize);
}
@override
void onWindowResize() {
_saveWindowSize();
}
@override
void onWindowResized() {
_saveWindowSize();
}
Widget build(BuildContext context) {
return MaterialApp(
title: 'Mis Notas',
debugShowCheckedModeBanner: false,
theme: AppTheme.theme,
home: const HomeScreen(),
);
}
}
+104
View File
@@ -0,0 +1,104 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
part 'app_database.g.dart';
@DataClassName('DbNote')
class Notes extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get title => text()();
TextColumn get body => text()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
IntColumn get sortIndex => integer().named('sort_index')();
}
@DriftDatabase(tables: [Notes])
class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 1;
Future<List<DbNote>> getAllNotes() {
return (select(notes)..orderBy([
(note) => OrderingTerm(expression: note.sortIndex),
])).get();
}
Future<int> insertNoteAtTop(NotesCompanion note) {
return transaction(() async {
await customStatement('UPDATE notes SET sort_index = sort_index + 1');
return into(notes).insert(note.copyWith(sortIndex: const Value<int>(0)));
});
}
Future<void> replaceAllNotes(List<NotesCompanion> noteList) {
return transaction(() async {
await delete(notes).go();
for (final NotesCompanion note in noteList) {
await into(notes).insert(note);
}
});
}
Future<void> updateNoteRow(DbNote note) {
return update(notes).replace(note);
}
Future<void> deleteNoteAndShift({
required int id,
required int removedIndex,
}) {
return transaction(() async {
await (delete(notes)..where((note) => note.id.equals(id))).go();
await customStatement(
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ?',
[removedIndex],
);
});
}
Future<void> moveNote({
required int id,
required int oldIndex,
required int newIndex,
}) {
if (oldIndex == newIndex) {
return Future<void>.value();
}
return transaction(() async {
if (oldIndex < newIndex) {
await customStatement(
'UPDATE notes SET sort_index = sort_index - 1 WHERE sort_index > ? AND sort_index <= ?',
[oldIndex, newIndex],
);
} else {
await customStatement(
'UPDATE notes SET sort_index = sort_index + 1 WHERE sort_index >= ? AND sort_index < ?',
[newIndex, oldIndex],
);
}
await customStatement(
'UPDATE notes SET sort_index = ? WHERE id = ?',
[newIndex, id],
);
});
}
}
LazyDatabase _openConnection() {
return LazyDatabase(() async {
final Directory supportDir = await getApplicationSupportDirectory();
final File file = File(p.join(supportDir.path, 'notes.sqlite'));
return NativeDatabase(file);
});
}
+624
View File
@@ -0,0 +1,624 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_database.dart';
// ignore_for_file: type=lint
class $NotesTable extends Notes with TableInfo<$NotesTable, DbNote> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$NotesTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<int> id = GeneratedColumn<int>(
'id',
aliasedName,
false,
hasAutoIncrement: true,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'PRIMARY KEY AUTOINCREMENT',
),
);
static const VerificationMeta _titleMeta = const VerificationMeta('title');
@override
late final GeneratedColumn<String> title = GeneratedColumn<String>(
'title',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _bodyMeta = const VerificationMeta('body');
@override
late final GeneratedColumn<String> body = GeneratedColumn<String>(
'body',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt',
);
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
);
static const VerificationMeta _updatedAtMeta = const VerificationMeta(
'updatedAt',
);
@override
late final GeneratedColumn<DateTime> updatedAt = GeneratedColumn<DateTime>(
'updated_at',
aliasedName,
false,
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
);
static const VerificationMeta _sortIndexMeta = const VerificationMeta(
'sortIndex',
);
@override
late final GeneratedColumn<int> sortIndex = GeneratedColumn<int>(
'sort_index',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
@override
List<GeneratedColumn> get $columns => [
id,
title,
body,
createdAt,
updatedAt,
sortIndex,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'notes';
@override
VerificationContext validateIntegrity(
Insertable<DbNote> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
}
if (data.containsKey('title')) {
context.handle(
_titleMeta,
title.isAcceptableOrUnknown(data['title']!, _titleMeta),
);
} else if (isInserting) {
context.missing(_titleMeta);
}
if (data.containsKey('body')) {
context.handle(
_bodyMeta,
body.isAcceptableOrUnknown(data['body']!, _bodyMeta),
);
} else if (isInserting) {
context.missing(_bodyMeta);
}
if (data.containsKey('created_at')) {
context.handle(
_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta),
);
} else if (isInserting) {
context.missing(_createdAtMeta);
}
if (data.containsKey('updated_at')) {
context.handle(
_updatedAtMeta,
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta),
);
} else if (isInserting) {
context.missing(_updatedAtMeta);
}
if (data.containsKey('sort_index')) {
context.handle(
_sortIndexMeta,
sortIndex.isAcceptableOrUnknown(data['sort_index']!, _sortIndexMeta),
);
} else if (isInserting) {
context.missing(_sortIndexMeta);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
DbNote map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return DbNote(
id: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}id'],
)!,
title: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}title'],
)!,
body: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}body'],
)!,
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}created_at'],
)!,
updatedAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}updated_at'],
)!,
sortIndex: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}sort_index'],
)!,
);
}
@override
$NotesTable createAlias(String alias) {
return $NotesTable(attachedDatabase, alias);
}
}
class DbNote extends DataClass implements Insertable<DbNote> {
final int id;
final String title;
final String body;
final DateTime createdAt;
final DateTime updatedAt;
final int sortIndex;
const DbNote({
required this.id,
required this.title,
required this.body,
required this.createdAt,
required this.updatedAt,
required this.sortIndex,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['title'] = Variable<String>(title);
map['body'] = Variable<String>(body);
map['created_at'] = Variable<DateTime>(createdAt);
map['updated_at'] = Variable<DateTime>(updatedAt);
map['sort_index'] = Variable<int>(sortIndex);
return map;
}
NotesCompanion toCompanion(bool nullToAbsent) {
return NotesCompanion(
id: Value(id),
title: Value(title),
body: Value(body),
createdAt: Value(createdAt),
updatedAt: Value(updatedAt),
sortIndex: Value(sortIndex),
);
}
factory DbNote.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return DbNote(
id: serializer.fromJson<int>(json['id']),
title: serializer.fromJson<String>(json['title']),
body: serializer.fromJson<String>(json['body']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
sortIndex: serializer.fromJson<int>(json['sortIndex']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'title': serializer.toJson<String>(title),
'body': serializer.toJson<String>(body),
'createdAt': serializer.toJson<DateTime>(createdAt),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
'sortIndex': serializer.toJson<int>(sortIndex),
};
}
DbNote copyWith({
int? id,
String? title,
String? body,
DateTime? createdAt,
DateTime? updatedAt,
int? sortIndex,
}) => DbNote(
id: id ?? this.id,
title: title ?? this.title,
body: body ?? this.body,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
sortIndex: sortIndex ?? this.sortIndex,
);
DbNote copyWithCompanion(NotesCompanion data) {
return DbNote(
id: data.id.present ? data.id.value : this.id,
title: data.title.present ? data.title.value : this.title,
body: data.body.present ? data.body.value : this.body,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
sortIndex: data.sortIndex.present ? data.sortIndex.value : this.sortIndex,
);
}
@override
String toString() {
return (StringBuffer('DbNote(')
..write('id: $id, ')
..write('title: $title, ')
..write('body: $body, ')
..write('createdAt: $createdAt, ')
..write('updatedAt: $updatedAt, ')
..write('sortIndex: $sortIndex')
..write(')'))
.toString();
}
@override
int get hashCode =>
Object.hash(id, title, body, createdAt, updatedAt, sortIndex);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is DbNote &&
other.id == this.id &&
other.title == this.title &&
other.body == this.body &&
other.createdAt == this.createdAt &&
other.updatedAt == this.updatedAt &&
other.sortIndex == this.sortIndex);
}
class NotesCompanion extends UpdateCompanion<DbNote> {
final Value<int> id;
final Value<String> title;
final Value<String> body;
final Value<DateTime> createdAt;
final Value<DateTime> updatedAt;
final Value<int> sortIndex;
const NotesCompanion({
this.id = const Value.absent(),
this.title = const Value.absent(),
this.body = const Value.absent(),
this.createdAt = const Value.absent(),
this.updatedAt = const Value.absent(),
this.sortIndex = const Value.absent(),
});
NotesCompanion.insert({
this.id = const Value.absent(),
required String title,
required String body,
required DateTime createdAt,
required DateTime updatedAt,
required int sortIndex,
}) : title = Value(title),
body = Value(body),
createdAt = Value(createdAt),
updatedAt = Value(updatedAt),
sortIndex = Value(sortIndex);
static Insertable<DbNote> custom({
Expression<int>? id,
Expression<String>? title,
Expression<String>? body,
Expression<DateTime>? createdAt,
Expression<DateTime>? updatedAt,
Expression<int>? sortIndex,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (title != null) 'title': title,
if (body != null) 'body': body,
if (createdAt != null) 'created_at': createdAt,
if (updatedAt != null) 'updated_at': updatedAt,
if (sortIndex != null) 'sort_index': sortIndex,
});
}
NotesCompanion copyWith({
Value<int>? id,
Value<String>? title,
Value<String>? body,
Value<DateTime>? createdAt,
Value<DateTime>? updatedAt,
Value<int>? sortIndex,
}) {
return NotesCompanion(
id: id ?? this.id,
title: title ?? this.title,
body: body ?? this.body,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
sortIndex: sortIndex ?? this.sortIndex,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
}
if (title.present) {
map['title'] = Variable<String>(title.value);
}
if (body.present) {
map['body'] = Variable<String>(body.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (updatedAt.present) {
map['updated_at'] = Variable<DateTime>(updatedAt.value);
}
if (sortIndex.present) {
map['sort_index'] = Variable<int>(sortIndex.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('NotesCompanion(')
..write('id: $id, ')
..write('title: $title, ')
..write('body: $body, ')
..write('createdAt: $createdAt, ')
..write('updatedAt: $updatedAt, ')
..write('sortIndex: $sortIndex')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
late final $NotesTable notes = $NotesTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [notes];
}
typedef $$NotesTableCreateCompanionBuilder =
NotesCompanion Function({
Value<int> id,
required String title,
required String body,
required DateTime createdAt,
required DateTime updatedAt,
required int sortIndex,
});
typedef $$NotesTableUpdateCompanionBuilder =
NotesCompanion Function({
Value<int> id,
Value<String> title,
Value<String> body,
Value<DateTime> createdAt,
Value<DateTime> updatedAt,
Value<int> sortIndex,
});
class $$NotesTableFilterComposer extends Composer<_$AppDatabase, $NotesTable> {
$$NotesTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get title => $composableBuilder(
column: $table.title,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get body => $composableBuilder(
column: $table.body,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get sortIndex => $composableBuilder(
column: $table.sortIndex,
builder: (column) => ColumnFilters(column),
);
}
class $$NotesTableOrderingComposer
extends Composer<_$AppDatabase, $NotesTable> {
$$NotesTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<int> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get title => $composableBuilder(
column: $table.title,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get body => $composableBuilder(
column: $table.body,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get sortIndex => $composableBuilder(
column: $table.sortIndex,
builder: (column) => ColumnOrderings(column),
);
}
class $$NotesTableAnnotationComposer
extends Composer<_$AppDatabase, $NotesTable> {
$$NotesTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get title =>
$composableBuilder(column: $table.title, builder: (column) => column);
GeneratedColumn<String> get body =>
$composableBuilder(column: $table.body, builder: (column) => column);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
GeneratedColumn<DateTime> get updatedAt =>
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
GeneratedColumn<int> get sortIndex =>
$composableBuilder(column: $table.sortIndex, builder: (column) => column);
}
class $$NotesTableTableManager
extends
RootTableManager<
_$AppDatabase,
$NotesTable,
DbNote,
$$NotesTableFilterComposer,
$$NotesTableOrderingComposer,
$$NotesTableAnnotationComposer,
$$NotesTableCreateCompanionBuilder,
$$NotesTableUpdateCompanionBuilder,
(DbNote, BaseReferences<_$AppDatabase, $NotesTable, DbNote>),
DbNote,
PrefetchHooks Function()
> {
$$NotesTableTableManager(_$AppDatabase db, $NotesTable table)
: super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$NotesTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$NotesTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$NotesTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
Value<int> id = const Value.absent(),
Value<String> title = const Value.absent(),
Value<String> body = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<DateTime> updatedAt = const Value.absent(),
Value<int> sortIndex = const Value.absent(),
}) => NotesCompanion(
id: id,
title: title,
body: body,
createdAt: createdAt,
updatedAt: updatedAt,
sortIndex: sortIndex,
),
createCompanionCallback:
({
Value<int> id = const Value.absent(),
required String title,
required String body,
required DateTime createdAt,
required DateTime updatedAt,
required int sortIndex,
}) => NotesCompanion.insert(
id: id,
title: title,
body: body,
createdAt: createdAt,
updatedAt: updatedAt,
sortIndex: sortIndex,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$NotesTableProcessedTableManager =
ProcessedTableManager<
_$AppDatabase,
$NotesTable,
DbNote,
$$NotesTableFilterComposer,
$$NotesTableOrderingComposer,
$$NotesTableAnnotationComposer,
$$NotesTableCreateCompanionBuilder,
$$NotesTableUpdateCompanionBuilder,
(DbNote, BaseReferences<_$AppDatabase, $NotesTable, DbNote>),
DbNote,
PrefetchHooks Function()
>;
class $AppDatabaseManager {
final _$AppDatabase _db;
$AppDatabaseManager(this._db);
$$NotesTableTableManager get notes =>
$$NotesTableTableManager(_db, _db.notes);
}
+80
View File
@@ -0,0 +1,80 @@
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();
final AppDatabase _database;
Future<List<Note>> loadNotes() async {
return _loadNotesFromDatabase();
}
Future<Note> createNote(Note note) async {
final int id = await _database.insertNoteAtTop(
NotesCompanion.insert(
title: note.title,
body: note.body,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
sortIndex: 0,
),
);
return note.copyWith(id: id, index: 0);
}
Future<Note> updateNote(Note note) async {
final int noteId = note.id ?? (throw ArgumentError('Note id is required to update a note.'));
await _database.updateNoteRow(
DbNote(
id: noteId,
title: note.title,
body: note.body,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
sortIndex: note.index,
),
);
return note;
}
Future<void> deleteNote(Note note) async {
final int noteId = note.id ?? (throw ArgumentError('Note id is required to delete a note.'));
await _database.deleteNoteAndShift(
id: noteId,
removedIndex: note.index,
);
}
Future<void> moveNote(Note note, int newIndex) async {
final int noteId = note.id ?? (throw ArgumentError('Note id is required to reorder a note.'));
await _database.moveNote(
id: noteId,
oldIndex: note.index,
newIndex: newIndex,
);
}
Future<List<Note>> _loadNotesFromDatabase() async {
final List<DbNote> rows = await _database.getAllNotes();
return rows.map(_fromRow).toList();
}
Note _fromRow(DbNote row) {
return Note(
id: row.id,
title: row.title,
body: row.body,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
index: row.sortIndex,
);
}
}
+9
View File
@@ -0,0 +1,9 @@
import 'package:flutter/material.dart';
import 'package:notas/app.dart';
import 'package:notas/platform/window_bootstrap.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await bootstrapWindow();
runApp(const NotesApp());
}
+51
View File
@@ -0,0 +1,51 @@
// Model: Note
// - Representa una nota guardada en la app.
// - `id` viene de SQLite y sirve como identificador estable.
// - `index` representa el orden visual dentro de la lista.
class Note {
const Note({
this.id,
required this.title,
required this.body,
required this.createdAt,
required this.updatedAt,
required this.index,
});
final int? id;
final String title;
final String body;
final DateTime createdAt;
final DateTime updatedAt;
final int index;
Note copyWith({
int? id,
String? title,
String? body,
DateTime? createdAt,
DateTime? updatedAt,
int? index,
}) {
return Note(
id: id ?? this.id,
title: title ?? this.title,
body: body ?? this.body,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
index: index ?? this.index,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
return other is Note && id != null && other.id == id;
}
@override
int get hashCode => id?.hashCode ?? Object.hash(title, body, createdAt, updatedAt, index);
}
+1
View File
@@ -0,0 +1 @@
export 'app_platform_stub.dart' if (dart.library.io) 'app_platform_io.dart';
+13
View File
@@ -0,0 +1,13 @@
import 'dart:io' show Platform;
bool get isAndroid => Platform.isAndroid;
bool get isIOS => Platform.isIOS;
bool get isLinux => Platform.isLinux;
bool get isMacOS => Platform.isMacOS;
bool get isWindows => Platform.isWindows;
bool get isDesktop => isLinux || isMacOS || isWindows;
+11
View File
@@ -0,0 +1,11 @@
bool get isAndroid => false;
bool get isIOS => false;
bool get isLinux => false;
bool get isMacOS => false;
bool get isWindows => false;
bool get isDesktop => false;
+1
View File
@@ -0,0 +1 @@
export 'window_bootstrap_stub.dart' if (dart.library.io) 'window_bootstrap_io.dart';
+29
View File
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:notas/platform/app_platform.dart';
import 'package:notas/platform/window_state.dart';
import 'package:window_manager/window_manager.dart';
Future<void> bootstrapWindow() async {
if (!isDesktop) {
return;
}
await windowManager.ensureInitialized();
final Size initialSize =
await WindowStateStore.instance.loadWindowSize() ?? const Size(900, 700);
final WindowOptions windowOptions = WindowOptions(
size: initialSize,
minimumSize: Size(400, 600),
center: true,
titleBarStyle: TitleBarStyle.hidden,
);
windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
await windowManager.setMinimumSize(const Size(400, 600));
await windowManager.setSize(initialSize);
await windowManager.focus();
});
}
+1
View File
@@ -0,0 +1 @@
Future<void> bootstrapWindow() async {}
+1
View File
@@ -0,0 +1 @@
export 'window_state_stub.dart' if (dart.library.io) 'window_state_io.dart';
+30
View File
@@ -0,0 +1,30 @@
import 'dart:ui';
import 'package:shared_preferences/shared_preferences.dart';
class WindowStateStore {
WindowStateStore._();
static final WindowStateStore instance = WindowStateStore._();
static const String _widthKey = 'window_width';
static const String _heightKey = 'window_height';
Future<Size?> loadWindowSize() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final double? width = prefs.getDouble(_widthKey);
final double? height = prefs.getDouble(_heightKey);
if (width == null || height == null) {
return null;
}
return Size(width, height);
}
Future<void> saveWindowSize(Size size) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_widthKey, size.width);
await prefs.setDouble(_heightKey, size.height);
}
}
+13
View File
@@ -0,0 +1,13 @@
import 'dart:ui';
class WindowStateStore {
WindowStateStore._();
static final WindowStateStore instance = WindowStateStore._();
Future<Size?> loadWindowSize() async {
return null;
}
Future<void> saveWindowSize(Size size) async {}
}
+402
View File
@@ -0,0 +1,402 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:notas/data/note_repository.dart';
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/widgets/note_card.dart';
import 'package:notas/widgets/search_app_bar.dart';
// HomeScreen: main entry showing notes in a responsive masonry grid.
// Key behaviors implemented here:
// - Load/save notes via `NoteRepository` (SQLite through Drift)
// - Open `NoteEditorScreen` for create/edit
// - Drag & drop reordering (updates `index` in the database)
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final NoteRepository _repository = NoteRepository();
List<Note> _notes = <Note>[];
String _searchQuery = '';
bool _isLoading = true;
bool _isDragging = false;
bool _isMenuOpen = false;
@override
void initState() {
super.initState();
_loadNotes();
}
Future<void> _loadNotes() async {
final List<Note> storedNotes = await _repository.loadNotes();
if (!mounted) {
return;
}
setState(() {
_notes = storedNotes;
_isLoading = false;
});
}
Future<void> _openNoteComposer() async {
final dynamic result = await NoteEditorScreen.showDialog(context);
if (result == null) {
return;
}
if (result is Note) {
final Note createdNote = await _repository.createNote(result);
final List<Note> updatedNotes = _normalizeNotes(<Note>[createdNote, ..._notes]);
if (!mounted) {
return;
}
setState(() {
_notes = updatedNotes;
});
}
}
Future<void> _deleteNote(Note note) async {
await _repository.deleteNote(note);
final List<Note> updatedNotes = _normalizeNotes(
_notes.where((Note item) => item.id != note.id).toList(),
);
if (!mounted) {
return;
}
setState(() {
_notes = updatedNotes;
});
}
Future<void> _reorderNote(int oldIndex, int newIndex) async {
if (oldIndex == newIndex) {
return;
}
final List<Note> updatedNotes = [..._notes];
final Note movedNote = updatedNotes.removeAt(oldIndex);
updatedNotes.insert(newIndex, movedNote);
await _repository.moveNote(movedNote, newIndex);
if (!mounted) {
return;
}
setState(() {
_notes = _normalizeNotes(updatedNotes);
});
}
Future<void> _openNoteEditor(Note note) async {
final dynamic result = await NoteEditorScreen.showDialog(context, note: note);
if (result == null) {
return;
}
if (result == 'delete') {
await _deleteNote(note);
return;
}
if (result is Note) {
final int noteIndex = _notes.indexWhere((Note item) => item == note);
if (noteIndex != -1) {
final Note savedNote = await _repository.updateNote(result);
final List<Note> updatedNotes = [..._notes];
updatedNotes[noteIndex] = savedNote;
if (!mounted) {
return;
}
setState(() {
_notes = _normalizeNotes(updatedNotes);
});
}
}
}
List<Note> _normalizeNotes(List<Note> notes) {
return notes.asMap().entries.map((MapEntry<int, Note> entry) {
return entry.value.copyWith(index: entry.key);
}).toList();
}
List<Note> _getFilteredNotes() {
if (_searchQuery.isEmpty) {
return _notes;
}
final String query = _searchQuery.toLowerCase();
return _notes
.where((Note note) =>
note.title.toLowerCase().contains(query) ||
note.body.toLowerCase().contains(query))
.toList();
}
@override
Widget build(BuildContext context) {
final double width = MediaQuery.of(context).size.width;
final int crossAxisCount = math.max((width / 250).floor().round(), 2);
final Widget body = _isLoading
? const Center(child: CircularProgressIndicator())
: _notes.isEmpty
? const _EmptyState()
: MouseRegion(
cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.basic,
child: MasonryGridView.count(
crossAxisCount: crossAxisCount,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
itemCount: _getFilteredNotes().length,
itemBuilder: (BuildContext context, int index) {
final List<Note> filteredNotes = _getFilteredNotes();
return DragTarget<int>(
onAcceptWithDetails: (DragTargetDetails<int> details) {
final Note targetNote = filteredNotes[index];
final int originalTargetIndex = _notes.indexOf(targetNote);
_reorderNote(details.data, originalTargetIndex);
},
builder: (context, candidateData, rejectedData) {
return LayoutBuilder(
builder: (context, constraints) {
final double cellWidth = constraints.maxWidth;
return Draggable<int>(
data: _notes.indexOf(filteredNotes[index]),
onDragStarted: () {
if (!mounted) return;
setState(() {
_isDragging = true;
});
},
onDragEnd: (_) {
if (!mounted) return;
setState(() {
_isDragging = false;
});
},
onDraggableCanceled: (_, _) {
if (!mounted) return;
setState(() {
_isDragging = false;
});
},
feedback: MouseRegion(
cursor: SystemMouseCursors.grabbing,
child: Material(
color: Colors.transparent,
elevation: 8,
child: SizedBox(
width: cellWidth,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.97, end: 1.0),
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
builder: (context, scale, child) {
return Transform.scale(
scale: scale,
alignment: Alignment.topLeft,
child: child,
);
},
child: Opacity(
opacity: 0.95,
child: NoteCard(note: filteredNotes[index], onTap: () {}, isDragging: true),
),
),
),
),
),
childWhenDragging: MouseRegion(
cursor: SystemMouseCursors.grabbing,
child: Opacity(
opacity: 0.3,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color.fromRGBO(24, 25, 26, 1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white24, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
filteredNotes[index].title,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
filteredNotes[index].body,
style: const TextStyle(color: Colors.white70, fontSize: 14),
maxLines: 20,
overflow: TextOverflow.clip,
),
],
),
),
),
),
child: Container(
decoration: BoxDecoration(
border: candidateData.isNotEmpty
? Border.all(color: Colors.blue.shade400, width: 2)
: null,
borderRadius: BorderRadius.circular(12),
),
child: NoteCard(
key: ValueKey<int>(filteredNotes[index].id ?? filteredNotes[index].index),
note: filteredNotes[index],
onTap: () => _openNoteEditor(filteredNotes[index]),
isDragging: _isDragging,
),
),
);
},
);
},
);
},
),
);
return Scaffold(
body: SafeArea(
child: Column(
children: [
const AppTitleBar(),
SearchAppBar(
onMenuPressed: () {
setState(() {
_isMenuOpen = !_isMenuOpen;
});
},
onSearchChanged: (String query) {
setState(() {
_searchQuery = query;
});
},
),
Expanded(
child: Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: body,
),
// Dark overlay when menu is open; clicking it closes the menu.
Positioned.fill(
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: 0,
bottom: 0,
width: 280,
child: Material(
color: const Color.fromRGBO(24, 25, 26, 1),
elevation: 8,
child: MenuDrawer(
onMenuItemTapped: (String item) {
setState(() {
_isMenuOpen = false;
});
},
),
),
),
],
),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _openNoteComposer,
child: const MouseRegion(
cursor: SystemMouseCursors.click,
child: Icon(Icons.add),
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.note_add_outlined, color: Colors.white54, size: 48),
SizedBox(height: 12),
Text(
'Aún no hay notas',
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
),
SizedBox(height: 8),
Text(
'Pulsa el botón + para crear la primera.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70),
),
],
),
);
}
}
+272
View File
@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:notas/models/note.dart';
// NoteEditorScreen: unified UI for creating and editing notes.
// - Use `NoteEditorScreen.showDialog(context, note: existing)` to edit.
// - Use `NoteEditorScreen.showDialog(context)` to create a new note.
// The screen returns either a `Note` (saved) or the string `'delete'` when
// the user confirmed deletion. `null` indicates the user closed without saving.
class NoteEditorScreen extends StatefulWidget {
const NoteEditorScreen({super.key, required this.note});
final Note? note;
@override
State<NoteEditorScreen> createState() => _NoteEditorScreenState();
static Future<dynamic> showDialog(BuildContext context, {Note? note}) {
return showGeneralDialog<dynamic>(
context: context,
barrierDismissible: false,
barrierColor: Colors.black.withValues(alpha: 0.5),
transitionDuration: const Duration(milliseconds: 200),
pageBuilder: (context, animation, secondaryAnimation) {
return NoteEditorScreen(note: note);
},
transitionBuilder: (context, animation, secondaryAnimation, child) {
return ScaleTransition(scale: animation, child: child);
},
);
}
}
class _NoteEditorScreenState extends State<NoteEditorScreen> {
late TextEditingController _titleController;
late TextEditingController _bodyController;
late Note _currentNote;
late bool _isNewNote;
@override
void initState() {
super.initState();
_isNewNote = widget.note?.id == null;
if (_isNewNote) {
final DateTime now = DateTime.now();
_currentNote = Note(
title: '',
body: '',
createdAt: now,
updatedAt: now,
index: 0,
);
} else {
_currentNote = widget.note!;
}
_titleController = TextEditingController(text: _currentNote.title);
_bodyController = TextEditingController(text: _currentNote.body);
}
@override
void dispose() {
_titleController.dispose();
_bodyController.dispose();
super.dispose();
}
void _closeWithoutSaving() {
Navigator.of(context).pop();
}
void _saveNote() {
final String title = _titleController.text.trim();
final String body = _bodyController.text.trim();
if (title.isEmpty && body.isEmpty) {
Navigator.of(context).pop();
return;
}
final Note updatedNote = _currentNote.copyWith(
title: title.isEmpty ? 'Sin título' : title,
body: body,
updatedAt: DateTime.now(),
);
Navigator.of(context).pop(updatedNote);
}
void _deleteNote() {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
backgroundColor: const Color(0xFF303134),
title: const Text('Eliminar nota', style: TextStyle(color: Colors.white)),
content: const Text(
'¿Estás seguro de que deseas eliminar esta nota?',
style: TextStyle(color: Colors.white70),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancelar', style: TextStyle(color: Colors.white70)),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
Navigator.of(context).pop('delete');
},
child: const Text('Eliminar', style: TextStyle(color: Colors.red)),
),
],
);
},
);
}
String _formatDate(DateTime date) {
final DateTime now = DateTime.now();
final DateTime today = DateTime(now.year, now.month, now.day);
final DateTime yesterday = today.subtract(const Duration(days: 1));
final DateTime noteDate = DateTime(date.year, date.month, date.day);
if (noteDate == today) {
return 'Hoy ${DateFormat('HH:mm').format(date)}';
} else if (noteDate == yesterday) {
return 'Ayer ${DateFormat('HH:mm').format(date)}';
} else {
return DateFormat('dd/MM/yyyy HH:mm').format(date);
}
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
child: Container(
constraints: const BoxConstraints(maxWidth: 600),
decoration: BoxDecoration(
color: const Color(0xFF202124),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header con botones y fechas
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.white12, width: 1),
),
),
child: Row(
children: [
IconButton(
onPressed: _closeWithoutSaving,
icon: const Icon(Icons.close, color: Colors.white70),
tooltip: 'Cerrar sin guardar',
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Creado: ${_formatDate(_currentNote.createdAt)}',
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
if (_currentNote.updatedAt != _currentNote.createdAt)
Text(
'Modificado: ${_formatDate(_currentNote.updatedAt)}',
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
],
),
),
],
),
),
// Contenido editable
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Título
TextField(
controller: _titleController,
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
decoration: const InputDecoration(
hintText: 'Título',
hintStyle: TextStyle(color: Colors.white30),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
const SizedBox(height: 16),
// Cuerpo de la nota
TextField(
controller: _bodyController,
maxLines: null,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
height: 1.6,
),
decoration: const InputDecoration(
hintText: 'Escribe tu nota...',
hintStyle: TextStyle(color: Colors.white30),
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
],
),
),
),
),
// Footer con botones de acción
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Colors.white12, width: 1),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Botón de borrar (izquierda) - solo para notas existentes
if (!_isNewNote)
IconButton(
onPressed: _deleteNote,
icon: const Icon(Icons.delete_outline, color: Colors.red),
tooltip: 'Eliminar nota',
)
else
const SizedBox(width: 48), // Espacio para mantener alineación
// Botón de guardar (derecha)
FilledButton(
onPressed: _saveNote,
child: const Text('Guardar'),
),
],
),
),
],
),
),
);
}
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
class AppTheme {
static ThemeData get theme {
return ThemeData(
useMaterial3: true,
scaffoldBackgroundColor: const Color.fromRGBO(31, 32, 33, 1),
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.amber,
brightness: Brightness.dark,
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: Colors.amber,
foregroundColor: Colors.black,
),
);
}
}
+1
View File
@@ -0,0 +1 @@
export 'app_title_bar_stub.dart' if (dart.library.io) 'app_title_bar_io.dart';
+224
View File
@@ -0,0 +1,224 @@
import 'package:flutter/material.dart';
import 'package:notas/platform/app_platform.dart';
import 'package:window_manager/window_manager.dart';
class AppTitleBar extends StatelessWidget {
const AppTitleBar({super.key});
@override
Widget build(BuildContext context) {
if (isAndroid || isIOS) {
return const SizedBox(height: 10);
}
if (isMacOS) {
return const SizedBox(
height: 28,
child: Center(
child: Text(
'Mis Notas',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
),
);
}
if (isLinux) {
return const _KdeTitleBar();
}
return const SizedBox(
height: 40,
child: WindowCaption(
brightness: Brightness.dark,
title: Text('Mis Notas', style: TextStyle(color: Colors.white)),
),
);
}
}
class _KdeTitleBar extends StatefulWidget {
const _KdeTitleBar();
@override
State<_KdeTitleBar> createState() => _KdeTitleBarState();
}
class _KdeTitleBarState extends State<_KdeTitleBar> with WindowListener {
bool _isFullScreen = false;
bool _isMaximized = false;
@override
void initState() {
super.initState();
if (isDesktop) {
windowManager.addListener(this);
_refreshWindowState();
}
}
@override
void dispose() {
if (isDesktop) {
windowManager.removeListener(this);
}
super.dispose();
}
Future<void> _refreshWindowState() async {
final bool isFullScreen = await windowManager.isFullScreen();
final bool isMaximized = await windowManager.isMaximized();
if (!mounted) {
return;
}
setState(() {
_isFullScreen = isFullScreen;
_isMaximized = isMaximized;
});
}
@override
void onWindowEnterFullScreen() {
setState(() {
_isFullScreen = true;
});
}
@override
void onWindowLeaveFullScreen() {
setState(() {
_isFullScreen = false;
});
}
@override
void onWindowMaximize() {
setState(() {
_isMaximized = true;
});
}
@override
void onWindowUnmaximize() {
setState(() {
_isMaximized = false;
});
}
@override
Widget build(BuildContext context) {
final IconData maximizeIcon =
(_isFullScreen || _isMaximized) ? Icons.fullscreen_exit : Icons.fullscreen;
return Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Colors.white.withValues(alpha: 0.12),
width: 0.5,
),
),
),
child: SizedBox(
height: 32,
child: Stack(
children: [
const DragToMoveArea(
child: SizedBox.expand(),
),
const IgnorePointer(
child: Center(
child: Text(
'Mis Notas',
style: TextStyle(
color: Color.fromARGB(255, 163, 163, 163),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
Positioned(
right: 0,
top: 0,
bottom: 0,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_KdeButton(
icon: Icons.minimize,
onPressed: () => windowManager.minimize(),
),
_KdeButton(
icon: maximizeIcon,
onPressed: () async {
if (await windowManager.isFullScreen()) {
await windowManager.setFullScreen(false);
} else if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
},
),
_KdeButton(
icon: Icons.close,
isClose: true,
onPressed: () => windowManager.close(),
),
],
),
),
],
),
),
);
}
}
class _KdeButton extends StatelessWidget {
const _KdeButton({
required this.icon,
required this.onPressed,
this.isClose = false,
});
final IconData icon;
final VoidCallback onPressed;
final bool isClose;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: SizedBox(
width: 32,
height: double.infinity,
child: Material(
color: Colors.transparent,
child: InkResponse(
highlightShape: BoxShape.circle,
containedInkWell: false,
radius: 10,
hoverColor: isClose ? Colors.red : Colors.white.withValues(alpha: 0.08),
onTap: onPressed,
child: Center(
child: Icon(
icon,
color: Colors.white70,
size: 16,
),
),
),
),
),
),
);
}
}
+10
View File
@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class AppTitleBar extends StatelessWidget {
const AppTitleBar({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox.shrink();
}
}
+71
View File
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
class MenuDrawer extends StatelessWidget {
const MenuDrawer({
super.key,
this.onMenuItemTapped,
});
final ValueChanged<String>? onMenuItemTapped;
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Color.fromRGBO(31, 32, 33, 1),
border: Border(
right: BorderSide(color: Colors.white12, width: 0.5),
),
),
child: Column(
children: [
_MenuItemTile(
icon: Icons.note,
label: 'Todas mis notas',
onTap: () => onMenuItemTapped?.call('all_notes'),
),
Expanded(
child: SingleChildScrollView(
child: Column(
children: const [
SizedBox.shrink(),
],
),
),
),
const Divider(color: Colors.white12, height: 16),
_MenuItemTile(
icon: Icons.settings,
label: 'Configuración',
onTap: () => onMenuItemTapped?.call('settings'),
),
],
),
);
}
}
class _MenuItemTile extends StatelessWidget {
const _MenuItemTile({
required this.icon,
required this.label,
this.onTap,
});
final IconData icon;
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(icon, color: Colors.white70),
title: Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
onTap: onTap,
hoverColor: Colors.white.withValues(alpha: 0.1),
);
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:notas/models/note.dart';
// Small presentational widget for a note inside the grid.
// Keep this widget lightweight and layout-agnostic: it should not force
// width/height constraints (so it works inside different parent layouts
// like MasonryGridView or Draggable feedback). Visual styling only.
class NoteCard extends StatefulWidget {
const NoteCard({super.key, required this.note, this.onTap, this.isDragging = false});
final Note note;
final VoidCallback? onTap;
final bool isDragging;
@override
State<NoteCard> createState() => _NoteCardState();
}
class _NoteCardState extends State<NoteCard> {
bool _isPressed = false;
@override
Widget build(BuildContext context) {
final bool showGrabbing = widget.isDragging || _isPressed;
return MouseRegion(
cursor: showGrabbing ? SystemMouseCursors.grabbing : SystemMouseCursors.grab,
child: GestureDetector(
onTapDown: widget.onTap == null
? null
: (_) {
setState(() {
_isPressed = true;
});
},
onTapUp: widget.onTap == null
? null
: (_) {
setState(() {
_isPressed = false;
});
},
onTapCancel: widget.onTap == null
? null
: () {
setState(() {
_isPressed = false;
});
},
onTap: widget.onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color.fromRGBO(24, 25, 26, 1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white24, width: 1),
),
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
// Estimate whether the body will exceed 20 lines without always
// running the expensive TextPainter layout. This heuristic counts
// newline characters and estimates wrapped lines based on an
// average characters-per-line to handle many short lines well.
final List<String> rawLines = widget.note.body.split('\n');
const int avgCharsPerLine = 40;
int estimatedLines = 0;
for (final String line in rawLines) {
estimatedLines += (line.trim().length ~/ avgCharsPerLine) + 1;
}
final bool needsPreciseMeasurement = estimatedLines > 20;
final bool isBodyTruncated;
if (needsPreciseMeasurement) {
final TextPainter textPainter = TextPainter(
text: TextSpan(
text: widget.note.body,
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
maxLines: 20,
textDirection: TextDirection.ltr,
)..layout(maxWidth: constraints.maxWidth);
isBodyTruncated = textPainter.didExceedMaxLines;
} else {
isBodyTruncated = false;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.note.title,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
widget.note.body,
style: const TextStyle(color: Colors.white70, fontSize: 14),
maxLines: 20,
overflow: TextOverflow.clip,
),
if (isBodyTruncated) ...[
const SizedBox(height: 4),
const Text(
'...',
style: TextStyle(
color: Colors.white54,
fontSize: 18,
height: 1,
),
),
],
],
);
},
),
),
),
);
}
}
+132
View File
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
class SearchAppBar extends StatefulWidget {
const SearchAppBar({
super.key,
this.onMenuPressed,
this.onSearchChanged,
this.searchHint = 'Buscar notas...',
});
final VoidCallback? onMenuPressed;
final ValueChanged<String>? onSearchChanged;
final String searchHint;
@override
State<SearchAppBar> createState() => _SearchAppBarState();
}
class _SearchAppBarState extends State<SearchAppBar> {
late TextEditingController _searchController;
@override
void initState() {
super.initState();
_searchController = TextEditingController();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: const Color.fromRGBO(31, 32, 33, 1),
border: Border(
bottom: BorderSide(
color: Colors.white.withValues(alpha: 0.12),
width: 0.5,
),
),
),
padding: const EdgeInsets.only(left: 16, right: 16, top: 7, bottom: 7),
child: Row(
children: [
// Menu button (fixed on left)
IconButton(
onPressed: widget.onMenuPressed,
icon: const Icon(Icons.menu, color: Colors.white70, size: 20),
tooltip: 'Menú',
splashRadius: 18,
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,
),
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,
),
),
enabledBorder: 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,
),
),
),
),
),
],
),
);
}
}