mirror of
https://github.com/cyberbread-ru/notepad.git
synced 2026-07-14 12:51:01 +03:00
initial vibecoded release
This commit is contained in:
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user