initial vibecoded release

This commit is contained in:
cyberbread-ru
2026-05-15 16:35:12 +04:00
commit 993478e6e3
78 changed files with 3044 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'repositories/local_note_repository.dart';
import 'repositories/remote_note_repository.dart';
import 'screens/note_list_screen.dart';
import 'screens/settings_screen.dart';
import 'services/api_client.dart';
import 'services/note_service.dart';
import 'services/sync_service.dart';
import 'viewmodels/note_view_model.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final settings = await AppSettings.load();
runApp(App(settings: settings));
}
class App extends StatelessWidget {
final ({String url, String apiKey}) settings;
const App({super.key, required this.settings});
@override
Widget build(BuildContext context) {
final localRepo = LocalNoteRepository();
SyncService? syncService;
if (AppSettings.isConfigured(url: settings.url, apiKey: settings.apiKey)) {
final apiClient = ApiClient(
baseUrl: settings.url,
apiKey: settings.apiKey,
);
final remoteRepo = RemoteNoteRepository(api: apiClient);
syncService = SyncService(local: localRepo, remote: remoteRepo);
}
final noteService = NoteService(local: localRepo, sync: syncService);
return ChangeNotifierProvider(
create: (_) => NoteViewModel(service: noteService)..loadNotes(),
child: MaterialApp(
title: 'Notepad',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const NoteListScreen(),
),
);
}
}
+66
View File
@@ -0,0 +1,66 @@
class Note {
final String id;
final String title;
final String content;
final DateTime createdAt;
final DateTime updatedAt;
const Note({
required this.id,
required this.title,
required this.content,
required this.createdAt,
required this.updatedAt,
});
Note copyWith({
String? title,
String? content,
DateTime? updatedAt,
}) {
return Note(
id: id,
title: title ?? this.title,
content: content ?? this.content,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'content': content,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
factory Note.fromJson(Map<String, dynamic> json) => Note(
id: json['id'] as String,
title: json['title'] as String,
content: json['content'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
);
// SQLite row → Note
factory Note.fromMap(Map<String, dynamic> map) => Note(
id: map['id'] as String,
title: map['title'] as String,
content: map['content'] as String,
createdAt: DateTime.parse(map['created_at'] as String),
updatedAt: DateTime.parse(map['updated_at'] as String),
);
// Note → SQLite row
Map<String, dynamic> toMap() => {
'id': id,
'title': title,
'content': content,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
@override
String toString() => 'Note(id: $id, title: $title)';
}
@@ -0,0 +1,65 @@
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import '../models/note.dart';
import 'note_repository.dart';
class LocalNoteRepository implements NoteRepository {
static Database? _db;
Future<Database> get _database async {
_db ??= await _initDb();
return _db!;
}
Future<Database> _initDb() async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, 'notes.db');
return openDatabase(
path,
version: 1,
onCreate: (db, version) => db.execute('''
CREATE TABLE notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
'''),
);
}
@override
Future<List<Note>> getAll() async {
final db = await _database;
final rows = await db.query('notes', orderBy: 'updated_at DESC');
return rows.map(Note.fromMap).toList();
}
@override
Future<Note?> getById(String id) async {
final db = await _database;
final rows = await db.query('notes', where: 'id = ?', whereArgs: [id]);
if (rows.isEmpty) return null;
return Note.fromMap(rows.first);
}
@override
Future<Note> save(Note note) async {
final db = await _database;
await db.insert(
'notes',
note.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
return note;
}
@override
Future<void> delete(String id) async {
final db = await _database;
await db.delete('notes', where: 'id = ?', whereArgs: [id]);
}
}
+8
View File
@@ -0,0 +1,8 @@
import '../models/note.dart';
abstract class NoteRepository {
Future<List<Note>> getAll();
Future<Note?> getById(String id);
Future<Note> save(Note note);
Future<void> delete(String id);
}
@@ -0,0 +1,42 @@
import '../models/note.dart';
import '../repositories/note_repository.dart';
import '../services/api_client.dart';
class RemoteNoteRepository implements NoteRepository {
final ApiClient _api;
RemoteNoteRepository({required ApiClient api}) : _api = api;
@override
Future<List<Note>> getAll() async {
final response = await _api.get('/notes/');
final List data = response.data as List;
return data.map((json) => Note.fromJson(json as Map<String, dynamic>)).toList();
}
@override
Future<Note?> getById(String id) async {
try {
final response = await _api.get('/notes/$id');
return Note.fromJson(response.data as Map<String, dynamic>);
} catch (_) {
return null;
}
}
@override
Future<Note> save(Note note) async {
final response = await _api.post('/notes/', note.toJson());
return Note.fromJson(response.data as Map<String, dynamic>);
}
Future<Note> update(Note note) async {
final response = await _api.put('/notes/${note.id}', note.toJson());
return Note.fromJson(response.data as Map<String, dynamic>);
}
@override
Future<void> delete(String id) async {
await _api.delete('/notes/$id');
}
}
+99
View File
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/note.dart';
import '../viewmodels/note_view_model.dart';
class NoteEditorScreen extends StatefulWidget {
final String noteId;
const NoteEditorScreen({super.key, required this.noteId});
@override
State<NoteEditorScreen> createState() => _NoteEditorScreenState();
}
class _NoteEditorScreenState extends State<NoteEditorScreen> {
late final TextEditingController _titleController;
late final TextEditingController _contentController;
late Note _note;
bool _isDirty = false;
@override
void initState() {
super.initState();
final vm = context.read<NoteViewModel>();
_note = vm.notes.firstWhere((n) => n.id == widget.noteId);
_titleController = TextEditingController(text: _note.title);
_contentController = TextEditingController(text: _note.content);
}
@override
void dispose() {
_titleController.dispose();
_contentController.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_isDirty) return;
final vm = context.read<NoteViewModel>();
final updated = _note.copyWith(
title: _titleController.text,
content: _contentController.text,
);
await vm.updateNote(updated);
setState(() => _isDirty = false);
}
void _onChanged() {
if (!_isDirty) setState(() => _isDirty = true);
}
@override
Widget build(BuildContext context) {
return PopScope(
onPopInvokedWithResult: (didPop, _) {
if (didPop) _save();
},
child: Scaffold(
appBar: AppBar(
titleSpacing: 0,
title: TextField(
controller: _titleController,
onChanged: (_) => _onChanged(),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
decoration: const InputDecoration(
hintText: 'Title',
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
actions: [
if (_isDirty)
IconButton(
icon: const Icon(Icons.check),
onPressed: _save,
tooltip: 'Save',
),
],
),
body: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: TextField(
controller: _contentController,
onChanged: (_) => _onChanged(),
maxLines: null,
expands: true,
keyboardType: TextInputType.multiline,
style: const TextStyle(fontSize: 16, height: 1.5),
decoration: const InputDecoration(
hintText: 'Start writing...',
border: InputBorder.none,
),
),
),
),
);
}
}
+221
View File
@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../viewmodels/note_view_model.dart';
import 'note_editor_screen.dart';
import 'settings_screen.dart';
class NoteListScreen extends StatelessWidget {
const NoteListScreen({super.key});
@override
Widget build(BuildContext context) {
final vm = context.watch<NoteViewModel>();
return Scaffold(
appBar: AppBar(
title: const Text('Notes'),
actions: [
IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () => _showMenu(context, vm),
),
],
),
body: _buildBody(context, vm),
floatingActionButton: FloatingActionButton(
onPressed: () => _createNote(context, vm),
child: const Icon(Icons.add),
),
);
}
Widget _buildBody(BuildContext context, NoteViewModel vm) {
if (vm.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (vm.error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(vm.error!, style: const TextStyle(color: Colors.red)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: vm.loadNotes,
child: const Text('Retry'),
),
],
),
);
}
if (vm.notes.isEmpty) {
return const Center(
child: Text(
'No notes yet.\nTap + to create one.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: vm.notes.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final note = vm.notes[index];
return ListTile(
title: Text(
note.title.isEmpty ? 'Untitled' : note.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
note.content.isEmpty ? 'No content' : note.content,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
_formatDate(note.updatedAt),
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
onTap: () => _openNote(context, note.id),
onLongPress: () => _confirmDelete(context, vm, note.id),
);
},
);
}
Future<void> _createNote(BuildContext context, NoteViewModel vm) async {
final note = await vm.createNote();
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => NoteEditorScreen(noteId: note.id)),
);
}
}
void _openNote(BuildContext context, String noteId) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => NoteEditorScreen(noteId: noteId)),
);
}
void _showMenu(BuildContext context, NoteViewModel vm) {
showModalBottomSheet(
context: context,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.settings_outlined),
title: const Text('Backup settings'),
onTap: () async {
Navigator.pop(context);
await _openSettings(context);
},
),
ListTile(
leading: const Icon(Icons.cloud_upload_outlined),
title: const Text('Backup to server'),
onTap: () {
Navigator.pop(context);
_runBackup(context, vm);
},
),
ListTile(
leading: const Icon(Icons.cloud_download_outlined),
title: const Text('Restore from server'),
onTap: () {
Navigator.pop(context);
_runRestore(context, vm);
},
),
],
),
),
);
}
Future<void> _openSettings(BuildContext context) async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
// Settings saved — user needs to restart the app for sync to activate.
// We'll improve this later when we add a proper service locator.
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Restart the app to apply backup settings.'),
),
);
}
}
Future<void> _runBackup(BuildContext context, NoteViewModel vm) async {
await vm.backup();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
vm.backupStatus == BackupStatus.success
? 'Backup successful'
: 'Backup failed: ${vm.error}',
),
),
);
}
}
Future<void> _runRestore(BuildContext context, NoteViewModel vm) async {
await vm.restore();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
vm.backupStatus == BackupStatus.success
? 'Restore successful'
: 'Restore failed: ${vm.error}',
),
),
);
}
}
Future<void> _confirmDelete(
BuildContext context, NoteViewModel vm, String id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Delete note?'),
content: const Text('This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true) await vm.deleteNote(id);
}
String _formatDate(DateTime dt) {
final now = DateTime.now();
if (dt.year == now.year && dt.month == now.month && dt.day == now.day) {
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
return '${dt.day}/${dt.month}/${dt.year}';
}
}
+204
View File
@@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final _urlController = TextEditingController();
final _keyController = TextEditingController();
bool _isSaving = false;
bool _obscureKey = true;
static const _urlPrefKey = 'server_url';
static const _apiKeyPrefKey = 'api_key';
@override
void initState() {
super.initState();
_loadSettings();
}
@override
void dispose() {
_urlController.dispose();
_keyController.dispose();
super.dispose();
}
Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
_urlController.text = prefs.getString(_urlPrefKey) ?? '';
_keyController.text = prefs.getString(_apiKeyPrefKey) ?? '';
}
Future<void> _saveSettings() async {
setState(() => _isSaving = true);
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_urlPrefKey, _urlController.text.trim());
await prefs.setString(_apiKeyPrefKey, _keyController.text.trim());
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Settings saved')),
);
Navigator.pop(context, true); // true = settings changed
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
Future<void> _clearSettings() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Clear settings?'),
content: const Text('Server URL and API key will be removed.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Clear', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true) {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_urlPrefKey);
await prefs.remove(_apiKeyPrefKey);
_urlController.clear();
_keyController.clear();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Settings cleared')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Backup settings'),
actions: [
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Clear settings',
onPressed: _clearSettings,
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.info_outline,
color: Theme.of(context).colorScheme.onSecondaryContainer,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Point to your running Python server. '
'Leave blank to use local storage only.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondaryContainer,
fontSize: 13,
),
),
),
],
),
),
const SizedBox(height: 24),
TextField(
controller: _urlController,
keyboardType: TextInputType.url,
autocorrect: false,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'http://192.168.1.x:8000',
prefixIcon: Icon(Icons.dns_outlined),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: _keyController,
obscureText: _obscureKey,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: 'API Key',
hintText: 'Your X-API-Key value',
prefixIcon: const Icon(Icons.key_outlined),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_obscureKey
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () => setState(() => _obscureKey = !_obscureKey),
),
),
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: _isSaving ? null : _saveSettings,
icon: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: Text(_isSaving ? 'Saving...' : 'Save'),
),
],
),
);
}
}
class AppSettings {
static const _urlPrefKey = 'server_url';
static const _apiKeyPrefKey = 'api_key';
static Future<({String url, String apiKey})> load() async {
final prefs = await SharedPreferences.getInstance();
return (
url: prefs.getString(_urlPrefKey) ?? '',
apiKey: prefs.getString(_apiKeyPrefKey) ?? '',
);
}
static bool isConfigured({required String url, required String apiKey}) {
return url.isNotEmpty && apiKey.isNotEmpty;
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:dio/dio.dart';
class ApiClient {
late final Dio _dio;
ApiClient({required String baseUrl, required String apiKey}) {
_dio = Dio(
BaseOptions(
baseUrl: baseUrl,
headers: {'X-API-Key': apiKey},
contentType: 'application/json',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
),
);
}
Future<Response> get(String path) => _dio.get(path);
Future<Response> post(String path, Map<String, dynamic> body) =>
_dio.post(path, data: body);
Future<Response> put(String path, Map<String, dynamic> body) =>
_dio.put(path, data: body);
Future<Response> delete(String path) => _dio.delete(path);
}
+53
View File
@@ -0,0 +1,53 @@
import 'package:uuid/uuid.dart';
import '../models/note.dart';
import '../repositories/local_note_repository.dart';
import 'sync_service.dart';
class NoteService {
final LocalNoteRepository _local;
final SyncService? _sync;
final _uuid = const Uuid();
NoteService({
required LocalNoteRepository local,
SyncService? sync,
}) : _local = local,
_sync = sync;
Future<List<Note>> getAllNotes() => _local.getAll();
Future<Note> createNote({String title = '', String content = ''}) async {
final note = Note(
id: _uuid.v4(),
title: title,
content: content,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
await _local.save(note);
return note;
}
Future<Note> updateNote(Note note) async {
final updated = note.copyWith(updatedAt: DateTime.now());
await _local.save(updated);
return updated;
}
Future<void> deleteNote(String id) async {
await _local.delete(id);
}
/// Manual backup — push everything to the server.
Future<void> triggerBackup() async {
if (_sync == null) throw Exception('Backup not configured');
await _sync!.pushAll();
}
/// Restore — pull everything from the server.
Future<void> triggerRestore() async {
if (_sync == null) throw Exception('Backup not configured');
await _sync!.pullAll();
}
}
+46
View File
@@ -0,0 +1,46 @@
import '../models/note.dart';
import '../repositories/local_note_repository.dart';
import '../repositories/remote_note_repository.dart';
class SyncService {
final LocalNoteRepository _local;
final RemoteNoteRepository _remote;
SyncService({
required LocalNoteRepository local,
required RemoteNoteRepository remote,
}) : _local = local,
_remote = remote;
/// Push all local notes to the server, overwriting whatever is there.
Future<void> pushAll() async {
final notes = await _local.getAll();
for (final note in notes) {
try {
await _remote.update(note);
} catch (_) {
// Note doesn't exist on server yet — create it
await _remote.save(note);
}
}
}
/// Pull all notes from the server, overwriting local storage.
Future<void> pullAll() async {
final notes = await _remote.getAll();
for (final note in notes) {
await _local.save(note);
}
}
/// Sync a single note by id — push local version to server.
Future<void> syncNote(String id) async {
final note = await _local.getById(id);
if (note == null) return;
try {
await _remote.update(note);
} catch (_) {
await _remote.save(note);
}
}
}
+93
View File
@@ -0,0 +1,93 @@
import 'package:flutter/foundation.dart';
import '../models/note.dart';
import '../services/note_service.dart';
enum BackupStatus { idle, loading, success, error }
class NoteViewModel extends ChangeNotifier {
final NoteService _service;
NoteViewModel({required NoteService service}) : _service = service;
List<Note> _notes = [];
bool _isLoading = false;
String? _error;
BackupStatus _backupStatus = BackupStatus.idle;
// ── Getters ────────────────────────────────────────────────
List<Note> get notes => _notes;
bool get isLoading => _isLoading;
String? get error => _error;
BackupStatus get backupStatus => _backupStatus;
// ── Notes CRUD ─────────────────────────────────────────────
Future<void> loadNotes() async {
_setLoading(true);
try {
_notes = await _service.getAllNotes();
_error = null;
} catch (e) {
_error = 'Failed to load notes: $e';
} finally {
_setLoading(false);
}
}
Future<Note> createNote() async {
final note = await _service.createNote();
_notes.insert(0, note);
notifyListeners();
return note;
}
Future<void> updateNote(Note note) async {
final updated = await _service.updateNote(note);
final index = _notes.indexWhere((n) => n.id == updated.id);
if (index != -1) {
_notes[index] = updated;
notifyListeners();
}
}
Future<void> deleteNote(String id) async {
await _service.deleteNote(id);
_notes.removeWhere((n) => n.id == id);
notifyListeners();
}
// ── Backup ─────────────────────────────────────────────────
Future<void> backup() async {
_setBackupStatus(BackupStatus.loading);
try {
await _service.triggerBackup();
_setBackupStatus(BackupStatus.success);
} catch (e) {
_error = e.toString();
_setBackupStatus(BackupStatus.error);
}
}
Future<void> restore() async {
_setBackupStatus(BackupStatus.loading);
try {
await _service.triggerRestore();
await loadNotes();
_setBackupStatus(BackupStatus.success);
} catch (e) {
_error = e.toString();
_setBackupStatus(BackupStatus.error);
}
}
// ── Helpers ────────────────────────────────────────────────
void _setLoading(bool value) {
_isLoading = value;
notifyListeners();
}
void _setBackupStatus(BackupStatus status) {
_backupStatus = status;
notifyListeners();
}
}