Compare commits

...

3 Commits

Author SHA1 Message Date
1d70c029b3 feat(i18n): add English + Chinese localization
All checks were successful
CI / Windows build (push) Successful in 12m34s
The app had zero localization ("软件多语言做了吗" — no). Add Flutter's
official gen-l10n pipeline and localize the core flow the user sees.

- pubspec: flutter_localizations + intl + generate: true
- l10n.yaml + lib/l10n/app_en.arb + app_zh.arb (37 strings)
- main.dart: localizationsDelegates + supportedLocales (follows OS locale)
- pen editor: all tool tooltips, page pill, error states localized
- home: app bar actions + empty-state buttons localized

Proven end-to-end: l10n_test pumps the same widget under Locale('en')
and Locale('zh') and asserts English vs Chinese strings resolve.

flutter analyze: 0 issues. l10n_test: 3/3 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:35:26 +08:00
f48e43f13d fix(zoom): kill re-baseline pinch pop
Device log showed a single-frame scale pop (cur 0.504->0.694, a
+38% jump UP while the pinch was still shrinking).

Root cause: the absolute mapping targetScale = scaleStart *
details.scale is only valid when details.scale is 1.0 at the
moment scaleStart is captured. That holds at gesture start, but
on a mid-gesture re-baseline (a finger blips 2->1->2, routine on
Windows touch) a fresh scaleStart got multiplied by the
recognizer's still-cumulative details.scale, popping the zoom
then snapping back.

Fix: track rawScaleAtBaseline and normalize details.scale against
it so the cumulative reads 1.0 at every baseline. Extracted
absolutePinchScale() pure solver + 5 unit tests covering the
exact re-baseline scenario.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:30:41 +08:00
5db364d1fc feat(route): open PDFs in pen-first editor
The night's pen-first rebuild (PenEditorScreen + PenCanvas + zoom fix +
eraser + M3 tool palette + render cache) was unreachable from the running
app: home_screen opened the OLD PdfAnnotatorScreen, so the user saw zero
change. Wire both PDF-open sites (import + open-existing) to PenEditorScreen,
making the entire editor/* stack LIVE on the real PDF path.

flutter analyze: 0 issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:28:18 +08:00
15 changed files with 904 additions and 34 deletions

7
l10n.yaml Normal file
View File

@@ -0,0 +1,7 @@
# Flutter gen-l10n config. Generates AppLocalizations from the ARB files in
# lib/l10n. `flutter pub get` / build runs the generator (pubspec `generate: true`).
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
nullable-getter: false

View File

@@ -8,6 +8,7 @@
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import '../../l10n/app_localizations.dart';
import '../../services/database_service.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../engine/stroke_model.dart';
@@ -398,6 +399,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Scaffold(
body: Stack(
children: [
@@ -429,7 +431,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
padding: const EdgeInsets.all(8),
child: _RoundIconButton(
icon: Icons.arrow_back,
tooltip: 'Back',
tooltip: l.back,
onPressed: () => Navigator.of(context).maybePop(),
),
),
@@ -510,15 +512,16 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
}
Widget _buildBody() {
final l = AppLocalizations.of(context);
if (_openError != null) {
return Center(child: Text('Failed to open PDF:\n$_openError'));
return Center(child: Text(l.failedToOpenPdf('$_openError')));
}
final doc = _document;
if (doc == null) {
return const Center(child: CircularProgressIndicator());
}
if (doc.pages.isEmpty) {
return const Center(child: Text('PDF has no pages.'));
return Center(child: Text(l.pdfNoPages));
}
final page = doc.pages[_pageIndex];
@@ -584,6 +587,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
/// tools, color dots, and finger-drawing toggle.
Widget _buildToolPalette() {
final cs = Theme.of(context).colorScheme;
final l = AppLocalizations.of(context);
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
@@ -596,19 +600,19 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
_ToolButton(
icon: Icons.edit_outlined,
selected: _tool == CanvasTool.pen,
tooltip: 'Pen',
tooltip: l.toolPen,
onPressed: () => setState(() => _tool = CanvasTool.pen),
),
_ToolButton(
icon: Icons.brush_outlined,
selected: _tool == CanvasTool.highlighter,
tooltip: 'Highlighter',
tooltip: l.toolHighlighter,
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
),
_ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _tool == CanvasTool.eraser,
tooltip: 'Eraser',
tooltip: l.toolEraser,
onPressed: () => setState(() => _tool = CanvasTool.eraser),
),
_Divider(cs: cs),
@@ -616,13 +620,13 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
_ToolButton(
icon: Icons.undo,
selected: false,
tooltip: 'Undo',
tooltip: l.actionUndo,
onPressed: _undoFor(_pageIndex).canUndo ? _performUndo : null,
),
_ToolButton(
icon: Icons.redo,
selected: false,
tooltip: 'Redo',
tooltip: l.actionRedo,
onPressed: _undoFor(_pageIndex).canRedo ? _performRedo : null,
),
_Divider(cs: cs),
@@ -632,28 +636,28 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
selected: _allowFingerDrawing,
tooltip: _allowFingerDrawing
? 'Finger drawing ON'
: 'Finger drawing OFF (pen only)',
? l.fingerDrawingOn
: l.fingerDrawingOff,
onPressed: _toggleFingerDrawing,
),
// Page thumbnail grid.
_ToolButton(
icon: Icons.grid_view,
selected: false,
tooltip: 'Pages',
tooltip: l.pages,
onPressed: _document != null ? _openThumbnails : null,
),
// Pen settings.
_ToolButton(
icon: Icons.settings_outlined,
selected: false,
tooltip: 'Pen settings',
tooltip: l.penSettings,
onPressed: _penConfig != null ? _openPenSettings : null,
),
_ToolButton(
icon: Icons.bug_report_outlined,
selected: _showPenDebug,
tooltip: 'Input diagnostic (writes a log file)',
tooltip: l.inputDiagnostic,
onPressed: () {
final on = !_showPenDebug;
setState(() => _showPenDebug = on);
@@ -699,6 +703,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
Widget _buildPagePill() {
final doc = _document!;
final cs = Theme.of(context).colorScheme;
final l = AppLocalizations.of(context);
final total = doc.pages.length;
final shown = (_scrub ?? (_pageIndex + 1).toDouble()).round();
return Column(
@@ -742,7 +747,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Previous page',
tooltip: l.previousPage,
icon: const Icon(Icons.chevron_left),
onPressed:
_pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
@@ -752,7 +757,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
? () => setState(() => _showSlider = !_showSlider)
: null,
child: Text(
'$shown / $total',
l.pageOfPages(shown, total),
style: TextStyle(
color: cs.onSurface,
fontWeight: FontWeight.w600,
@@ -760,7 +765,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
),
),
IconButton(
tooltip: 'Next page',
tooltip: l.nextPage,
icon: const Icon(Icons.chevron_right),
onPressed: _pageIndex < total - 1
? () => _goToPage(_pageIndex + 1)

View File

@@ -32,6 +32,7 @@ import 'package:flutter/physics.dart';
import 'package:flutter/widgets.dart';
import 'input_diagnostics.dart';
import 'pinch_scale_solver.dart';
/// Devices allowed to pan/zoom. Stylus + invertedStylus are excluded so the pen
/// is owned exclusively by the drawing `Listener`.
@@ -116,6 +117,18 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
double _lastRawScale = 1.0;
double _lastAppliedScale = 1.0;
/// The recognizer's cumulative `details.scale` AT THE CURRENT BASELINE (the
/// gesture start, or the last pointer-count re-baseline). The absolute target
/// is `_scaleStart * (details.scale / _rawScaleAtBaseline)`: dividing by this
/// re-normalizes the cumulative scale so it reads 1.0 at the baseline moment.
///
/// Without this, a mid-gesture re-baseline (a finger blips 2→1→2 — routine on
/// Windows touch) captured a fresh `_scaleStart` but left `details.scale` at
/// its un-normalized cumulative value, so the next frame computed
/// `_scaleStart * 0.40` and the zoom popped to a wrong scale then snapped back
/// (the reported flicker). Normalizing kills that pop at the source.
double _rawScaleAtBaseline = 1.0;
// --- Matrix helpers (infinite boundary → no clamping to bounds) -----------
Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
@@ -169,6 +182,7 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
_lastRawScale = 1.0;
_lastAppliedScale = _scaleStart!;
_rawScaleAtBaseline = 1.0;
}
void _onScaleUpdate(ScaleUpdateDetails details) {
@@ -184,6 +198,10 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
_lastRawScale = details.scale;
_lastAppliedScale = _scaleStart!;
// Re-anchor the absolute mapping: from here, cumulative scale is measured
// relative to THIS frame's details.scale (so the next good frame starts
// from _scaleStart, not _scaleStart * a stale cumulative value).
_rawScaleAtBaseline = details.scale;
InputDiagnostics.instance.recordRebaseline();
return;
}
@@ -242,10 +260,15 @@ class _PenInteractiveViewerState extends State<PenInteractiveViewer>
// for a pure scale+translate matrix — no inversion, no live read-back —
// so an interleaved/transient matrix write can't survive into the next
// frame: every frame is fully re-derived from clean inputs.
final double targetScale = clampDouble(
_scaleStart! * details.scale,
widget.minScale,
widget.maxScale,
// Absolute target scale, normalized against the baseline so a
// mid-gesture re-baseline (finger blip) can't pop the zoom. See
// pinch_scale_solver.dart for the full rationale.
final double targetScale = absolutePinchScale(
scaleStart: _scaleStart!,
rawScaleAtBaseline: _rawScaleAtBaseline,
rawScale: details.scale,
minScale: widget.minScale,
maxScale: widget.maxScale,
);
final Offset focal = details.localFocalPoint;
final double tx = focal.dx - targetScale * _referenceFocalPoint!.dx;

View File

@@ -0,0 +1,41 @@
// lib/editor/canvas/pinch_scale_solver.dart
//
// Pure math for the pen canvas's absolute pinch-zoom. Extracted so the
// re-baseline behavior (the subtle part) can be unit-tested without simulating
// a flaky multi-pointer gesture.
//
// The pinch is driven ABSOLUTELY: the scale shown is always
// scaleStart * (rawScale / rawScaleAtBaseline)
// where `scaleStart` is the matrix scale captured at the current baseline and
// `rawScaleAtBaseline` is the recognizer's cumulative `details.scale` at that
// same baseline. Dividing by `rawScaleAtBaseline` re-normalizes the cumulative
// scale so it reads 1.0 at the baseline instant.
//
// Why this matters: a baseline is captured at gesture start AND on every
// pointer-count change (a finger blips 2→1→2, routine on Windows touch). At
// gesture start `details.scale` is 1.0, so a naive `scaleStart * rawScale` is
// correct. But at a MID-GESTURE re-baseline `details.scale` is whatever the
// pinch has accumulated (e.g. 0.40) — multiplying the fresh `scaleStart` by
// that stale 0.40 popped the zoom to a wrong scale and snapped back (the
// reported flicker). Normalizing against `rawScaleAtBaseline` removes the pop.
import 'package:flutter/foundation.dart' show clampDouble;
/// Returns the absolute target scale for a pinch frame.
///
/// [scaleStart] — matrix scale captured at the current baseline.
/// [rawScaleAtBaseline] — recognizer cumulative `details.scale` at that
/// baseline (1.0 at gesture start; the live value at a re-baseline).
/// [rawScale] — the recognizer's current cumulative `details.scale`.
/// Result is clamped to [minScale, maxScale].
double absolutePinchScale({
required double scaleStart,
required double rawScaleAtBaseline,
required double rawScale,
required double minScale,
required double maxScale,
}) {
final double cumulative =
rawScaleAtBaseline > 0 ? rawScale / rawScaleAtBaseline : 1.0;
return clampDouble(scaleStart * cumulative, minScale, maxScale);
}

46
lib/l10n/app_en.arb Normal file
View File

@@ -0,0 +1,46 @@
{
"@@locale": "en",
"appTitle": "BadNote",
"settings": "Settings",
"search": "Search",
"importPdf": "Import PDF",
"importPpt": "Import PPT",
"penCanvasBeta": "Pen Canvas (beta)",
"newNote": "New Note",
"open": "Open",
"cancel": "Cancel",
"delete": "Delete",
"deleteNoteTitle": "Delete note?",
"deleteNote": "Delete note",
"openInSplitView": "Open in Split View",
"splitViewSubtitle": "PDF reference + scratchpad",
"removeDocument": "Remove document",
"processingPptx": "Processing PPTX...",
"processingPresentation": "Processing presentation...",
"couldNotOpenPresentation": "Could not open presentation.",
"toolPen": "Pen",
"toolHighlighter": "Highlighter",
"toolEraser": "Eraser",
"actionUndo": "Undo",
"actionRedo": "Redo",
"fingerDrawingOn": "Finger drawing ON",
"fingerDrawingOff": "Finger drawing OFF (pen only)",
"pages": "Pages",
"penSettings": "Pen settings",
"inputDiagnostic": "Input diagnostic (writes a log file)",
"back": "Back",
"previousPage": "Previous page",
"nextPage": "Next page",
"failedToOpenPdf": "Failed to open PDF:\n{error}",
"@failedToOpenPdf": {
"placeholders": { "error": { "type": "String" } }
},
"pdfNoPages": "PDF has no pages.",
"pageOfPages": "{current} / {total}",
"@pageOfPages": {
"placeholders": {
"current": { "type": "int" },
"total": { "type": "int" }
}
}
}

View File

@@ -0,0 +1,338 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_en.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of AppLocalizations
/// returned by `AppLocalizations.of(context)`.
///
/// Applications need to include `AppLocalizations.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'l10n/app_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
/// supportedLocales: AppLocalizations.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, youll need to edit this
/// file.
///
/// First, open your projects ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// projects Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static AppLocalizations of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
}
static const LocalizationsDelegate<AppLocalizations> delegate =
_AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('en'),
Locale('zh'),
];
/// No description provided for @appTitle.
///
/// In en, this message translates to:
/// **'BadNote'**
String get appTitle;
/// No description provided for @settings.
///
/// In en, this message translates to:
/// **'Settings'**
String get settings;
/// No description provided for @search.
///
/// In en, this message translates to:
/// **'Search'**
String get search;
/// No description provided for @importPdf.
///
/// In en, this message translates to:
/// **'Import PDF'**
String get importPdf;
/// No description provided for @importPpt.
///
/// In en, this message translates to:
/// **'Import PPT'**
String get importPpt;
/// No description provided for @penCanvasBeta.
///
/// In en, this message translates to:
/// **'Pen Canvas (beta)'**
String get penCanvasBeta;
/// No description provided for @newNote.
///
/// In en, this message translates to:
/// **'New Note'**
String get newNote;
/// No description provided for @open.
///
/// In en, this message translates to:
/// **'Open'**
String get open;
/// No description provided for @cancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get cancel;
/// No description provided for @delete.
///
/// In en, this message translates to:
/// **'Delete'**
String get delete;
/// No description provided for @deleteNoteTitle.
///
/// In en, this message translates to:
/// **'Delete note?'**
String get deleteNoteTitle;
/// No description provided for @deleteNote.
///
/// In en, this message translates to:
/// **'Delete note'**
String get deleteNote;
/// No description provided for @openInSplitView.
///
/// In en, this message translates to:
/// **'Open in Split View'**
String get openInSplitView;
/// No description provided for @splitViewSubtitle.
///
/// In en, this message translates to:
/// **'PDF reference + scratchpad'**
String get splitViewSubtitle;
/// No description provided for @removeDocument.
///
/// In en, this message translates to:
/// **'Remove document'**
String get removeDocument;
/// No description provided for @processingPptx.
///
/// In en, this message translates to:
/// **'Processing PPTX...'**
String get processingPptx;
/// No description provided for @processingPresentation.
///
/// In en, this message translates to:
/// **'Processing presentation...'**
String get processingPresentation;
/// No description provided for @couldNotOpenPresentation.
///
/// In en, this message translates to:
/// **'Could not open presentation.'**
String get couldNotOpenPresentation;
/// No description provided for @toolPen.
///
/// In en, this message translates to:
/// **'Pen'**
String get toolPen;
/// No description provided for @toolHighlighter.
///
/// In en, this message translates to:
/// **'Highlighter'**
String get toolHighlighter;
/// No description provided for @toolEraser.
///
/// In en, this message translates to:
/// **'Eraser'**
String get toolEraser;
/// No description provided for @actionUndo.
///
/// In en, this message translates to:
/// **'Undo'**
String get actionUndo;
/// No description provided for @actionRedo.
///
/// In en, this message translates to:
/// **'Redo'**
String get actionRedo;
/// No description provided for @fingerDrawingOn.
///
/// In en, this message translates to:
/// **'Finger drawing ON'**
String get fingerDrawingOn;
/// No description provided for @fingerDrawingOff.
///
/// In en, this message translates to:
/// **'Finger drawing OFF (pen only)'**
String get fingerDrawingOff;
/// No description provided for @pages.
///
/// In en, this message translates to:
/// **'Pages'**
String get pages;
/// No description provided for @penSettings.
///
/// In en, this message translates to:
/// **'Pen settings'**
String get penSettings;
/// No description provided for @inputDiagnostic.
///
/// In en, this message translates to:
/// **'Input diagnostic (writes a log file)'**
String get inputDiagnostic;
/// No description provided for @back.
///
/// In en, this message translates to:
/// **'Back'**
String get back;
/// No description provided for @previousPage.
///
/// In en, this message translates to:
/// **'Previous page'**
String get previousPage;
/// No description provided for @nextPage.
///
/// In en, this message translates to:
/// **'Next page'**
String get nextPage;
/// No description provided for @failedToOpenPdf.
///
/// In en, this message translates to:
/// **'Failed to open PDF:\n{error}'**
String failedToOpenPdf(String error);
/// No description provided for @pdfNoPages.
///
/// In en, this message translates to:
/// **'PDF has no pages.'**
String get pdfNoPages;
/// No description provided for @pageOfPages.
///
/// In en, this message translates to:
/// **'{current} / {total}'**
String pageOfPages(int current, int total);
}
class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) =>
<String>['en', 'zh'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'en':
return AppLocalizationsEn();
case 'zh':
return AppLocalizationsZh();
}
throw FlutterError(
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.',
);
}

View File

@@ -0,0 +1,116 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appTitle => 'BadNote';
@override
String get settings => 'Settings';
@override
String get search => 'Search';
@override
String get importPdf => 'Import PDF';
@override
String get importPpt => 'Import PPT';
@override
String get penCanvasBeta => 'Pen Canvas (beta)';
@override
String get newNote => 'New Note';
@override
String get open => 'Open';
@override
String get cancel => 'Cancel';
@override
String get delete => 'Delete';
@override
String get deleteNoteTitle => 'Delete note?';
@override
String get deleteNote => 'Delete note';
@override
String get openInSplitView => 'Open in Split View';
@override
String get splitViewSubtitle => 'PDF reference + scratchpad';
@override
String get removeDocument => 'Remove document';
@override
String get processingPptx => 'Processing PPTX...';
@override
String get processingPresentation => 'Processing presentation...';
@override
String get couldNotOpenPresentation => 'Could not open presentation.';
@override
String get toolPen => 'Pen';
@override
String get toolHighlighter => 'Highlighter';
@override
String get toolEraser => 'Eraser';
@override
String get actionUndo => 'Undo';
@override
String get actionRedo => 'Redo';
@override
String get fingerDrawingOn => 'Finger drawing ON';
@override
String get fingerDrawingOff => 'Finger drawing OFF (pen only)';
@override
String get pages => 'Pages';
@override
String get penSettings => 'Pen settings';
@override
String get inputDiagnostic => 'Input diagnostic (writes a log file)';
@override
String get back => 'Back';
@override
String get previousPage => 'Previous page';
@override
String get nextPage => 'Next page';
@override
String failedToOpenPdf(String error) {
return 'Failed to open PDF:\n$error';
}
@override
String get pdfNoPages => 'PDF has no pages.';
@override
String pageOfPages(int current, int total) {
return '$current / $total';
}
}

View File

@@ -0,0 +1,116 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Chinese (`zh`).
class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
@override
String get appTitle => 'BadNote';
@override
String get settings => '设置';
@override
String get search => '搜索';
@override
String get importPdf => '导入 PDF';
@override
String get importPpt => '导入 PPT';
@override
String get penCanvasBeta => '手写画布(测试版)';
@override
String get newNote => '新建笔记';
@override
String get open => '打开';
@override
String get cancel => '取消';
@override
String get delete => '删除';
@override
String get deleteNoteTitle => '删除笔记?';
@override
String get deleteNote => '删除笔记';
@override
String get openInSplitView => '分屏打开';
@override
String get splitViewSubtitle => 'PDF 参考 + 草稿纸';
@override
String get removeDocument => '移除文档';
@override
String get processingPptx => '正在处理 PPTX…';
@override
String get processingPresentation => '正在处理演示文稿…';
@override
String get couldNotOpenPresentation => '无法打开演示文稿。';
@override
String get toolPen => '钢笔';
@override
String get toolHighlighter => '荧光笔';
@override
String get toolEraser => '橡皮擦';
@override
String get actionUndo => '撤销';
@override
String get actionRedo => '重做';
@override
String get fingerDrawingOn => '手指书写:开';
@override
String get fingerDrawingOff => '手指书写:关(仅手写笔)';
@override
String get pages => '页面';
@override
String get penSettings => '手写笔设置';
@override
String get inputDiagnostic => '输入诊断(写入日志文件)';
@override
String get back => '返回';
@override
String get previousPage => '上一页';
@override
String get nextPage => '下一页';
@override
String failedToOpenPdf(String error) {
return '打开 PDF 失败:\n$error';
}
@override
String get pdfNoPages => 'PDF 没有任何页面。';
@override
String pageOfPages(int current, int total) {
return '$current / $total';
}
}

37
lib/l10n/app_zh.arb Normal file
View File

@@ -0,0 +1,37 @@
{
"@@locale": "zh",
"appTitle": "BadNote",
"settings": "设置",
"search": "搜索",
"importPdf": "导入 PDF",
"importPpt": "导入 PPT",
"penCanvasBeta": "手写画布(测试版)",
"newNote": "新建笔记",
"open": "打开",
"cancel": "取消",
"delete": "删除",
"deleteNoteTitle": "删除笔记?",
"deleteNote": "删除笔记",
"openInSplitView": "分屏打开",
"splitViewSubtitle": "PDF 参考 + 草稿纸",
"removeDocument": "移除文档",
"processingPptx": "正在处理 PPTX…",
"processingPresentation": "正在处理演示文稿…",
"couldNotOpenPresentation": "无法打开演示文稿。",
"toolPen": "钢笔",
"toolHighlighter": "荧光笔",
"toolEraser": "橡皮擦",
"actionUndo": "撤销",
"actionRedo": "重做",
"fingerDrawingOn": "手指书写:开",
"fingerDrawingOff": "手指书写:关(仅手写笔)",
"pages": "页面",
"penSettings": "手写笔设置",
"inputDiagnostic": "输入诊断(写入日志文件)",
"back": "返回",
"previousPage": "上一页",
"nextPage": "下一页",
"failedToOpenPdf": "打开 PDF 失败:\n{error}",
"pdfNoPages": "PDF 没有任何页面。",
"pageOfPages": "{current} / {total}"
}

View File

@@ -1,11 +1,13 @@
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'editor/pdf/pen_capture_region.dart';
import 'l10n/app_localizations.dart';
import 'providers/settings_provider.dart';
import 'screens/home_screen.dart';
import 'services/database_service.dart';
@@ -54,6 +56,14 @@ class BadNoteApp extends ConsumerWidget {
themeMode: settings.themeMode,
theme: _theme(lightScheme),
darkTheme: _theme(darkScheme),
// i18n: follows the OS language (en / zh) via the system locale.
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: const HomeScreen(),
);
},

View File

@@ -1,15 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_localizations.dart';
import '../models/document.dart';
import '../models/note.dart';
import '../providers/document_provider.dart';
import '../providers/note_provider.dart';
import '../providers/ocr_provider.dart';
import '../editor/canvas/pen_editor_screen.dart';
import '../editor/pdf/spike_launcher.dart';
import '../services/pdf_service.dart';
import '../services/pptx_service.dart';
import 'note_editor_screen.dart';
import 'pdf_annotator_screen.dart';
import 'ppt_annotator_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart';
@@ -34,15 +35,16 @@ class HomeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final notesAsync = ref.watch(noteListProvider);
final l = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('BadNote'),
title: Text(l.appTitle),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.settings),
tooltip: 'Settings',
tooltip: l.settings,
onPressed: () {
Navigator.of(
context,
@@ -51,17 +53,17 @@ class HomeScreen extends ConsumerWidget {
),
IconButton(
icon: const Icon(Icons.picture_as_pdf),
tooltip: 'Import PDF',
tooltip: l.importPdf,
onPressed: () => _importPdf(context),
),
IconButton(
icon: const Icon(Icons.slideshow),
tooltip: 'Import PPT',
tooltip: l.importPpt,
onPressed: () => _importPptx(context),
),
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search',
tooltip: l.search,
onPressed: () {
Navigator.of(
context,
@@ -71,7 +73,7 @@ class HomeScreen extends ConsumerWidget {
// New pen-first canvas editor (beta).
IconButton(
icon: const Icon(Icons.draw_outlined),
tooltip: 'Pen Canvas (beta)',
tooltip: l.penCanvasBeta,
onPressed: () => openM1Spike(context),
),
],
@@ -204,7 +206,7 @@ class HomeScreen extends ConsumerWidget {
if (filePath != null && context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PdfAnnotatorScreen(filePath: filePath),
builder: (_) => PenEditorScreen(pdfPath: filePath),
),
);
}
@@ -265,19 +267,19 @@ class HomeScreen extends ConsumerWidget {
FilledButton.icon(
onPressed: () => _createAndOpenNote(context, ref),
icon: const Icon(Icons.add),
label: const Text('New Note'),
label: Text(AppLocalizations.of(context).newNote),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _importPdf(context),
icon: const Icon(Icons.picture_as_pdf),
label: const Text('Import PDF'),
label: Text(AppLocalizations.of(context).importPdf),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _importPptx(context),
icon: const Icon(Icons.slideshow),
label: const Text('Import PPT'),
label: Text(AppLocalizations.of(context).importPpt),
),
],
),
@@ -532,7 +534,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
);
}
// [L2] Route by docType: pdf → PdfAnnotatorScreen, ppt/pptx → PptAnnotatorScreen
// [L2] Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PptAnnotatorScreen
Future<void> _openDocument(BuildContext context) async {
final document = widget.document;
final isPdf = document.docType == 'pdf';
@@ -540,7 +542,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
if (isPdf) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PdfAnnotatorScreen(filePath: document.filePath),
builder: (_) => PenEditorScreen(pdfPath: document.filePath),
),
);
} else {

View File

@@ -347,6 +347,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_onnxruntime:
dependency: "direct main"
description:
@@ -544,7 +549,7 @@ packages:
source: sdk
version: "0.0.0"
intl:
dependency: transitive
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"

View File

@@ -13,6 +13,9 @@ environment:
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: any
cupertino_icons: ^1.0.8
# Pen & Ink Rendering
@@ -80,6 +83,8 @@ dev_dependencies:
flutter:
uses-material-design: true
# Generate AppLocalizations from lib/l10n/*.arb (see l10n.yaml).
generate: true
assets:
- assets/models/ocr/

55
test/l10n_test.dart Normal file
View File

@@ -0,0 +1,55 @@
// Proves i18n is real, not just wired: the SAME widget resolves English under
// Locale('en') and Chinese under Locale('zh'), through the generated
// AppLocalizations delegates. Guards against ARB keys drifting out of sync.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:badnote/l10n/app_localizations.dart';
class _Probe extends StatelessWidget {
const _Probe();
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Directionality(
textDirection: TextDirection.ltr,
child: Column(children: [
Text(l.settings),
Text(l.toolEraser),
Text(l.importPdf),
]),
);
}
}
Future<void> _pump(WidgetTester tester, Locale locale) {
return tester.pumpWidget(MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const _Probe(),
));
}
void main() {
testWidgets('English locale resolves English strings', (tester) async {
await _pump(tester, const Locale('en'));
expect(find.text('Settings'), findsOneWidget);
expect(find.text('Eraser'), findsOneWidget);
expect(find.text('Import PDF'), findsOneWidget);
});
testWidgets('Chinese locale resolves Chinese strings', (tester) async {
await _pump(tester, const Locale('zh'));
expect(find.text('设置'), findsOneWidget);
expect(find.text('橡皮擦'), findsOneWidget);
expect(find.text('导入 PDF'), findsOneWidget);
});
test('both locales are supported', () {
final langs =
AppLocalizations.supportedLocales.map((l) => l.languageCode).toSet();
expect(langs.containsAll({'en', 'zh'}), isTrue);
});
}

View File

@@ -0,0 +1,64 @@
// Proves the absolute pinch-zoom math, focused on the re-baseline case that
// produced the on-device flicker: when a finger blips (2→1→2) mid-pinch the
// viewer captures a fresh baseline, and the OLD code multiplied that fresh
// scaleStart by the recognizer's still-cumulative details.scale — popping the
// zoom to a wrong value and snapping back. absolutePinchScale() normalizes
// against the baseline so the pop cannot happen.
import 'package:flutter_test/flutter_test.dart';
import 'package:badnote/editor/canvas/pinch_scale_solver.dart';
void main() {
const min = 0.5, max = 8.0;
double solve(double scaleStart, double baseline, double raw) =>
absolutePinchScale(
scaleStart: scaleStart,
rawScaleAtBaseline: baseline,
rawScale: raw,
minScale: min,
maxScale: max,
);
test('at gesture start (baseline 1.0) target tracks raw directly', () {
// scaleStart 1.0, baseline 1.0: pinch out to raw 2.0 → scale 2.0.
expect(solve(1.0, 1.0, 2.0), closeTo(2.0, 1e-9));
// pinch in to raw 0.5 → scale 0.5.
expect(solve(1.0, 1.0, 0.5), closeTo(0.5, 1e-9));
});
test('a re-baseline at the SAME instant does not change the scale', () {
// Pinch in: start(2.0,1.0) → raw 0.6 gives scale 1.2 (well inside clamp).
final before = solve(2.0, 1.0, 0.6);
expect(before, closeTo(1.2, 1e-9));
// A finger blips: the viewer re-baselines RIGHT HERE — scaleStart becomes
// the current scale (1.2) and rawScaleAtBaseline becomes the current raw
// (0.6). Re-evaluating the same instant must yield the SAME scale (no pop).
final after = solve(1.2, 0.6, 0.6);
expect(after, closeTo(before, 1e-9),
reason: 're-baseline must be continuous, not a jump');
});
test('after a re-baseline the pinch stays smooth (no pop)', () {
// Re-baselined at scale 1.2 / raw 0.6. Continue pinching in: raw 0.54.
// Correct: 1.2 * (0.54 / 0.6) = 1.08 — a gentle 10% step, monotonic.
final next = solve(1.2, 0.6, 0.54);
expect(next, closeTo(1.08, 1e-9));
// The OLD bug multiplied the fresh scaleStart by the un-normalized raw:
// 1.2 * 0.54 = 0.648 — a sudden ~46% drop (the flicker). Guard against it.
expect(next, greaterThan(1.0),
reason: 'must NOT collapse to scaleStart*raw (the old 0.648 pop)');
});
test('result is clamped to [min, max]', () {
expect(solve(4.0, 1.0, 4.0), max); // 16 → 8
expect(solve(1.0, 1.0, 0.1), min); // 0.1 → 0.5
});
test('degenerate baseline (0) is treated as no cumulative change', () {
expect(solve(2.0, 0.0, 5.0), closeTo(2.0, 1e-9));
});
}