Fix bugs across app + server, optimize UI/UX, add Gitea CI
Some checks failed
CI / Test (Server, optional) (push) Failing after 2m10s
Windows Build / Build Windows (x64) (push) Failing after 29s
CI / Test (Flutter, Linux) (push) Has been cancelled
CI / Analyze (Flutter) (push) Has been cancelled

Bug fixes (Flutter):
- Wrap multi-statement DB writes (insert/update/delete note, deleteDocument,
  deletePageData, OCR FTS merge, migrations) in transactions to prevent data
  loss on interruption and a read-modify-write FTS race.
- Fix PdfDocument leaks on exception (try/finally dispose) and preserve image
  aspect ratio when stamping images onto PDF pages.
- Guard file-picker against empty selection (was .single -> crash).
- Fix eraser ConcurrentModificationError and unmodifiable-list crash on PDF
  pages; capture page synchronously on save to stop wrong-page data loss.
- Fix Riverpod DB-not-ready races, broken pull-to-refresh, settings load race,
  and search N+1; transform stored annotations on PDF page rotation.
- Normalize pen pressure for devices without a pressure range.
- PPT: single source of truth for slide strokes so ink displays and exports.

UI/UX:
- Material 3 typography, theme-aware colors (dark-mode fixes), hover cursors
  and right-click/visible actions on desktop, keyboard shortcuts (undo/redo/
  save/find), toolbar overflow handling, friendlier empty states, semantic OCR
  status badges, relative timestamps, 1-based page indicators, large-deck PPT
  navigation, and a scratchpad-scope label in split view.

Server (optional backend):
- Persist JWT secret (was per-process random), block path traversal in storage,
  fix CORS '*'+credentials, add OCR job ownership checks, last-writer-wins sync
  guard, constant-time login, and split out heavy OCR deps so the API/tests run
  without them.

CI: Gitea workflows for format+analyze+test (Linux, system sqlite) and a
Windows release build; pristine `flutter analyze`, all Flutter and server tests
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 03:18:00 +08:00
commit 72428dc075
210 changed files with 18171 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../models/document.dart';
import '../services/database_service.dart';
import 'note_provider.dart';
const _uuid = Uuid();
final documentListProvider =
AsyncNotifierProvider<DocumentListNotifier, List<Document>>(
DocumentListNotifier.new,
);
class DocumentListNotifier extends AsyncNotifier<List<Document>> {
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future);
@override
Future<List<Document>> build() async {
final db = await _db;
return db.getAllDocuments();
}
/// Reloads documents from the database and publishes the result to [state]
/// so the UI rebuilds. Used by pull-to-refresh.
Future<void> loadDocuments() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final db = await _db;
return db.getAllDocuments();
});
}
Future<Document> addDocument({
required String filename,
required String docType,
required String filePath,
int pageCount = 0,
}) async {
final db = await _db;
final now = DateTime.now();
final document = Document(
id: _uuid.v4(),
filename: filename,
docType: docType,
filePath: filePath,
pageCount: pageCount,
createdAt: now,
updatedAt: now,
);
await db.insertDocument(document);
state = AsyncData([document, ...state.value ?? []]);
return document;
}
Future<void> removeDocument(String id) async {
final db = await _db;
await db.deleteDocument(id);
final current = state.value ?? [];
state = AsyncData(current.where((d) => d.id != id).toList());
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../models/note.dart';
import '../services/database_service.dart';
const _uuid = Uuid();
final databaseServiceProvider = FutureProvider<DatabaseService>((ref) async {
return DatabaseService.getInstance();
});
final noteListProvider = AsyncNotifierProvider<NoteListNotifier, List<Note>>(
NoteListNotifier.new,
);
class NoteListNotifier extends AsyncNotifier<List<Note>> {
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future);
@override
Future<List<Note>> build() async {
final db = await _db;
return db.getAllNotes();
}
/// Reloads notes from the database and publishes the result to [state] so
/// the UI rebuilds. Used by pull-to-refresh.
Future<void> loadNotes() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final db = await _db;
return db.getAllNotes();
});
}
Future<Note> createNote({String title = 'Untitled'}) async {
final db = await _db;
final now = DateTime.now();
final note = Note(
id: _uuid.v4(),
title: title,
createdAt: now,
updatedAt: now,
);
await db.insertNote(note);
state = AsyncData([note, ...state.value ?? []]);
return note;
}
Future<void> updateNote(Note note) async {
final db = await _db;
await db.updateNote(note);
final current = state.value ?? [];
state = AsyncData(current.map((n) => n.id == note.id ? note : n).toList());
}
Future<void> deleteNote(String id) async {
final db = await _db;
await db.deleteNote(id);
final current = state.value ?? [];
state = AsyncData(current.where((n) => n.id != id).toList());
}
}
final noteProvider = FutureProvider.family<Note?, String>((ref, id) async {
final db = await ref.watch(databaseServiceProvider.future);
return db.getNoteById(id);
});

View File

@@ -0,0 +1,43 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/ocr_service.dart';
enum OcrStatus { none, processing, done, failed }
final ocrServiceProvider = Provider<OcrService>((ref) => OcrService());
/// Tracks local OCR processing status per note ID.
///
/// This map only ever holds an entry per note that has had OCR triggered in
/// the current session. To keep it from growing without bound over a long
/// session, prune terminal/stale entries via [OcrStatusX] (e.g. remove an
/// entry once its result has been surfaced, or call [OcrStatusX.pruneOcr]
/// after a sweep). Kept as a [StateProvider] so existing call sites that
/// assign `ocrStatusProvider.notifier.state` continue to work.
final ocrStatusProvider = StateProvider<Map<String, OcrStatus>>((ref) => {});
/// Pruning helpers for [ocrStatusProvider] that keep its backing map bounded.
extension OcrStatusX on Ref {
/// Removes the tracked status for [noteId] (e.g. when its note is deleted
/// or its result has been consumed by the UI).
void clearOcr(String noteId) {
final current = read(ocrStatusProvider);
if (!current.containsKey(noteId)) return;
read(ocrStatusProvider.notifier).state = Map<String, OcrStatus>.from(
current,
)..remove(noteId);
}
/// Drops all completed/failed entries, keeping only in-flight work so the
/// map stays bounded.
void pruneOcr() {
final current = read(ocrStatusProvider);
final next = <String, OcrStatus>{
for (final entry in current.entries)
if (entry.value == OcrStatus.processing) entry.key: entry.value,
};
if (next.length != current.length) {
read(ocrStatusProvider.notifier).state = next;
}
}
}

View File

@@ -0,0 +1,93 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/document.dart';
import '../models/note.dart';
import 'note_provider.dart';
final searchQueryProvider = StateProvider<String>((ref) => '');
/// A search result that can be either a note hit or a document hit.
sealed class SearchResult {
const SearchResult();
}
class NoteSearchHit extends SearchResult {
final Note note;
final String snippet;
const NoteSearchHit({required this.note, this.snippet = ''});
}
class DocumentSearchHit extends SearchResult {
final String documentId;
final String filename;
final String filePath;
final int pageNumber;
final String snippet;
const DocumentSearchHit({
required this.documentId,
required this.filename,
required this.filePath,
required this.pageNumber,
this.snippet = '',
});
}
final searchResultsProvider = FutureProvider<List<SearchResult>>((ref) async {
final query = ref.watch(searchQueryProvider);
if (query.isEmpty) return [];
// Obtain the DB through the provider graph so this participates in
// initialization and disposal like every other consumer.
final db = await ref.watch(databaseServiceProvider.future);
// Run the note and document searches concurrently.
final searches = await Future.wait([
db.searchNotes(query),
db.searchDocuments(query),
]);
final noteHits = searches[0] as List<Note>;
final docHits = searches[1] as List<Map<String, dynamic>>;
final results = <SearchResult>[];
// Add note results.
for (final note in noteHits) {
results.add(NoteSearchHit(note: note, snippet: note.title));
}
// Resolve document metadata without an N+1 loop: collect the distinct
// document ids referenced by the hits, look each up exactly once, then
// build the result list from the cached lookups.
final docIds = <String>{
for (final hit in docHits)
if (hit['document_id'] is String) hit['document_id'] as String,
};
final docEntries = await Future.wait(
docIds.map((id) async => MapEntry(id, await db.getDocument(id))),
);
final docsById = <String, Document>{
for (final entry in docEntries)
if (entry.value != null) entry.key: entry.value!,
};
for (final hit in docHits) {
final documentId = hit['document_id'];
if (documentId is! String) continue;
final doc = docsById[documentId];
if (doc == null) continue;
final pageNumber = hit['page_number'];
final content = hit['content'];
results.add(
DocumentSearchHit(
documentId: documentId,
filename: doc.filename,
filePath: doc.filePath,
pageNumber: pageNumber is int ? pageNumber : 0,
snippet: content is String ? content : '',
),
);
}
return results;
});

View File

@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../utils/stroke_stabilizer.dart';
final settingsProvider = ChangeNotifierProvider<SettingsNotifier>(
(ref) => SettingsNotifier(),
);
/// Persists user settings across sessions using SharedPreferences.
class SettingsNotifier extends ChangeNotifier {
late SharedPreferences _prefs;
/// Completes once [_load] has assigned [_prefs]. Setters await this before
/// touching [_prefs] to avoid a LateInitializationError when invoked before
/// the fire-and-forget load from the constructor finishes.
late final Future<void> _ready;
PenTool _defaultTool = PenTool.pen;
Color _defaultColor = Colors.black;
double _defaultStrokeWidth = 2.0;
PressureCurveType _defaultPressureCurve = PressureCurveType.linear;
StabilizationLevel _defaultStabilization = StabilizationLevel.none;
ThemeMode _themeMode = ThemeMode.system;
Color _colorSchemeSeed = Colors.blue;
SettingsNotifier() {
_ready = _load();
}
PenTool get defaultTool => _defaultTool;
Color get defaultColor => _defaultColor;
double get defaultStrokeWidth => _defaultStrokeWidth;
PressureCurveType get defaultPressureCurve => _defaultPressureCurve;
StabilizationLevel get defaultStabilization => _defaultStabilization;
ThemeMode get themeMode => _themeMode;
Color get colorSchemeSeed => _colorSchemeSeed;
Future<void> setDefaultTool(PenTool tool) async {
_defaultTool = tool;
notifyListeners();
await _ready;
await _prefs.setString('defaultTool', tool.name);
}
Future<void> setDefaultColor(Color color) async {
_defaultColor = color;
notifyListeners();
await _ready;
await _prefs.setInt('defaultColor', color.toARGB32());
}
Future<void> setDefaultStrokeWidth(double width) async {
_defaultStrokeWidth = width;
notifyListeners();
await _ready;
await _prefs.setDouble('defaultStrokeWidth', width);
}
Future<void> setDefaultPressureCurve(PressureCurveType curve) async {
_defaultPressureCurve = curve;
notifyListeners();
await _ready;
await _prefs.setString('defaultPressureCurve', curve.name);
}
Future<void> setDefaultStabilization(StabilizationLevel level) async {
_defaultStabilization = level;
notifyListeners();
await _ready;
await _prefs.setString('defaultStabilization', level.name);
}
Future<void> setThemeMode(ThemeMode mode) async {
_themeMode = mode;
notifyListeners();
await _ready;
await _prefs.setString('themeMode', mode.name);
}
Future<void> setColorSchemeSeed(Color color) async {
_colorSchemeSeed = color;
notifyListeners();
await _ready;
await _prefs.setInt('colorSchemeSeed', color.toARGB32());
}
Future<void> clearAllData() async {
await _ready;
await _prefs.clear();
_defaultTool = PenTool.pen;
_defaultColor = Colors.black;
_defaultStrokeWidth = 2.0;
_defaultPressureCurve = PressureCurveType.linear;
_defaultStabilization = StabilizationLevel.none;
_themeMode = ThemeMode.system;
_colorSchemeSeed = Colors.blue;
notifyListeners();
}
Future<void> _load() async {
_prefs = await SharedPreferences.getInstance();
final toolName = _prefs.getString('defaultTool');
if (toolName != null) {
_defaultTool = PenTool.values.asNameMap()[toolName] ?? PenTool.pen;
}
final colorValue = _prefs.getInt('defaultColor');
if (colorValue != null) {
_defaultColor = Color(colorValue);
}
_defaultStrokeWidth =
_prefs.getDouble('defaultStrokeWidth') ?? _defaultStrokeWidth;
final curveName = _prefs.getString('defaultPressureCurve');
if (curveName != null) {
_defaultPressureCurve =
PressureCurveType.values.asNameMap()[curveName] ??
PressureCurveType.linear;
}
final stabName = _prefs.getString('defaultStabilization');
if (stabName != null) {
_defaultStabilization =
StabilizationLevel.values.asNameMap()[stabName] ??
StabilizationLevel.none;
}
final themeName = _prefs.getString('themeMode');
if (themeName != null) {
_themeMode = ThemeMode.values.asNameMap()[themeName] ?? ThemeMode.system;
}
final seedValue = _prefs.getInt('colorSchemeSeed');
if (seedValue != null) {
_colorSchemeSeed = Color(seedValue);
}
notifyListeners();
}
}