diff --git a/README.md b/README.md index 1de35e1..8b0adaa 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,12 @@ OpenNotes is designed to be local and private. The app does not include analytic If you export, share, back up, or import files through another app or operating-system service, that service's behavior is outside OpenNotes. +Public legal and support pages: + +- [Privacy Policy](https://mathnotes-app.github.io/OpenNotes/privacy/) +- [Terms of Use](https://mathnotes-app.github.io/OpenNotes/terms/) +- [Support](https://mathnotes-app.github.io/OpenNotes/support/) + ## Contributing Bug reports, feature requests, and focused pull requests are welcome. Start with [CONTRIBUTING.md](CONTRIBUTING.md), and please avoid attaching private notes or documents to public issues. diff --git a/android/app/build.gradle b/android/app/build.gradle index 8c940d0..17654ea 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -87,13 +87,13 @@ android { buildToolsVersion rootProject.ext.buildToolsVersion compileSdk rootProject.ext.compileSdkVersion - namespace 'com.opennotes.app' + namespace 'com.builderpro.opennotes' defaultConfig { - applicationId 'com.opennotes.app' + applicationId 'com.builderpro.opennotes' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "0.1.0" + versionCode 6 + versionName "1.0" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } diff --git a/android/app/src/main/java/com/opennotes/app/MainActivity.kt b/android/app/src/main/java/com/builderpro/opennotes/MainActivity.kt similarity index 98% rename from android/app/src/main/java/com/opennotes/app/MainActivity.kt rename to android/app/src/main/java/com/builderpro/opennotes/MainActivity.kt index ae1b2fe..ecedd50 100644 --- a/android/app/src/main/java/com/opennotes/app/MainActivity.kt +++ b/android/app/src/main/java/com/builderpro/opennotes/MainActivity.kt @@ -1,4 +1,4 @@ -package com.opennotes.app +package com.builderpro.opennotes import android.os.Build import android.os.Bundle diff --git a/android/app/src/main/java/com/opennotes/app/MainApplication.kt b/android/app/src/main/java/com/builderpro/opennotes/MainApplication.kt similarity index 98% rename from android/app/src/main/java/com/opennotes/app/MainApplication.kt rename to android/app/src/main/java/com/builderpro/opennotes/MainApplication.kt index bea22a6..b78641d 100644 --- a/android/app/src/main/java/com/opennotes/app/MainApplication.kt +++ b/android/app/src/main/java/com/builderpro/opennotes/MainApplication.kt @@ -1,4 +1,4 @@ -package com.opennotes.app +package com.builderpro.opennotes import android.app.Application import android.content.res.Configuration diff --git a/android/app/src/main/java/com/opennotes/app/PDFUtilsModule.kt b/android/app/src/main/java/com/builderpro/opennotes/PDFUtilsModule.kt similarity index 99% rename from android/app/src/main/java/com/opennotes/app/PDFUtilsModule.kt rename to android/app/src/main/java/com/builderpro/opennotes/PDFUtilsModule.kt index 7dd8d5d..fb90d9e 100644 --- a/android/app/src/main/java/com/opennotes/app/PDFUtilsModule.kt +++ b/android/app/src/main/java/com/builderpro/opennotes/PDFUtilsModule.kt @@ -1,4 +1,4 @@ -package com.opennotes.app +package com.builderpro.opennotes import android.graphics.pdf.PdfRenderer import android.net.Uri diff --git a/android/app/src/main/java/com/opennotes/app/PDFUtilsPackage.kt b/android/app/src/main/java/com/builderpro/opennotes/PDFUtilsPackage.kt similarity index 97% rename from android/app/src/main/java/com/opennotes/app/PDFUtilsPackage.kt rename to android/app/src/main/java/com/builderpro/opennotes/PDFUtilsPackage.kt index 65582de..47c223b 100644 --- a/android/app/src/main/java/com/opennotes/app/PDFUtilsPackage.kt +++ b/android/app/src/main/java/com/builderpro/opennotes/PDFUtilsPackage.kt @@ -1,4 +1,4 @@ -package com.opennotes.app +package com.builderpro.opennotes import com.facebook.react.TurboReactPackage import com.facebook.react.bridge.NativeModule diff --git a/app.json b/app.json index 3263daf..394cf3a 100644 --- a/app.json +++ b/app.json @@ -2,13 +2,21 @@ "expo": { "name": "OpenNotes", "slug": "open-notes", - "version": "0.1.0", + "version": "1.0", "scheme": "opennotes", "orientation": "default", + "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", "newArchEnabled": true, + "splash": { + "image": "./assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#F7F7F4" + }, "ios": { - "bundleIdentifier": "com.opennotes.app", + "bundleIdentifier": "com.builderpro.opennotes", + "icon": "./assets/icon.png", + "buildNumber": "6", "supportsTablet": true, "infoPlist": { "NSPhotoLibraryUsageDescription": "OpenNotes needs access to your photo library so you can insert images into your notes.", @@ -45,7 +53,12 @@ } }, "android": { - "package": "com.opennotes.app", + "package": "com.builderpro.opennotes", + "versionCode": 6, + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#F7F7F4" + }, "permissions": [ "android.permission.READ_EXTERNAL_STORAGE", "android.permission.WRITE_EXTERNAL_STORAGE", diff --git a/app/folder/[id].tsx b/app/folder/[id].tsx index 3c27970..cf8d65a 100644 --- a/app/folder/[id].tsx +++ b/app/folder/[id].tsx @@ -26,6 +26,7 @@ import { listFolders, renameFolder, } from '../../src/services/foldersRepo'; +import { recordReviewSignal } from '../../src/services/reviewPromptService'; import type { BackgroundType, FolderMetadata, NoteMetadata } from '../../src/types/note'; type Action = @@ -81,12 +82,16 @@ export default function FolderScreen() { backgroundType, title: title.trim() || undefined, }); + void recordReviewSignal('note_created'); router.push(`/note/${meta.id}`); return; } const meta = await createPdfNoteFromPicker({ folderId: folder.id, title }); - if (meta) router.push(`/note/${meta.id}`); + if (meta) { + void recordReviewSignal('note_created'); + router.push(`/note/${meta.id}`); + } } catch (error) { if (__DEV__) console.warn('[FolderScreen] create note failed', error); Alert.alert('Could not create note', 'Please try again.'); @@ -162,7 +167,10 @@ export default function FolderScreen() { router.push(`/note/${note.id}`)} + onPress={() => { + void recordReviewSignal('note_opened'); + router.push(`/note/${note.id}`); + }} onLongPress={() => { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); setAction({ kind: 'noteMenu', note }); diff --git a/app/index.tsx b/app/index.tsx index 9e1d31f..bf12ef0 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -3,6 +3,7 @@ import { ActivityIndicator, Alert, Linking, + Pressable, ScrollView, StyleSheet, Text, @@ -39,8 +40,20 @@ import { listFolders, renameFolder, } from '../src/services/foldersRepo'; +import { + recordReviewSignal, + requestReviewAfterPositiveMoment, +} from '../src/services/reviewPromptService'; import type { BackgroundType, FolderMetadata, NoteMetadata } from '../src/types/note'; +const LEGAL_URLS = { + privacy: 'https://mathnotes-app.github.io/OpenNotes/privacy/', + terms: 'https://mathnotes-app.github.io/OpenNotes/terms/', + support: 'https://mathnotes-app.github.io/OpenNotes/support/', + github: 'https://github.com/mathnotes-app/OpenNotes', + x: 'https://x.com/markpm39', +}; + type Action = | { kind: 'newItem' } | { kind: 'about' } @@ -78,6 +91,7 @@ export default function LibraryScreen() { useFocusEffect( useCallback(() => { void refresh(); + void requestReviewAfterPositiveMoment(); }, [refresh]), ); @@ -103,6 +117,7 @@ export default function LibraryScreen() { const openNote = useCallback( (id: string) => { void Haptics.selectionAsync(); + void recordReviewSignal('note_opened'); router.push(`/note/${id}`); }, [router], @@ -127,12 +142,16 @@ export default function LibraryScreen() { backgroundType, title: title.trim() || undefined, }); + void recordReviewSignal('note_created'); openNote(meta.id); return; } const meta = await createPdfNoteFromPicker({ folderId: null, title }); - if (meta) openNote(meta.id); + if (meta) { + void recordReviewSignal('note_created'); + openNote(meta.id); + } } catch (error) { if (__DEV__) console.warn('[LibraryScreen] create note failed', error); Alert.alert('Could not create note', 'Please try again.'); @@ -233,13 +252,13 @@ export default function LibraryScreen() { key: 'github', icon: 'logo-github', accessibilityLabel: 'Open OpenNotes on GitHub', - onPress: () => void openUrl('https://github.com/mathnotes-app/OpenNotes'), + onPress: () => void openUrl(LEGAL_URLS.github), }, { key: 'x', icon: 'logo-x', accessibilityLabel: 'Open Mark Miller on X', - onPress: () => void openUrl('https://x.com/markpm39'), + onPress: () => void openUrl(LEGAL_URLS.x), }, { key: 'about', @@ -471,6 +490,14 @@ function AboutSheet({ onClose: () => void; }) { const theme = useTheme(); + const openUrl = useCallback(async (url: string) => { + try { + await Linking.openURL(url); + } catch (error) { + if (__DEV__) console.warn('[AboutSheet] open link failed', error); + Alert.alert('Could not open link', 'Please try again.'); + } + }, []); return ( @@ -506,10 +533,34 @@ function AboutSheet({ OpenNotes is powered by the open source Mobile Ink engine. + + void openUrl(LEGAL_URLS.privacy)} /> + void openUrl(LEGAL_URLS.terms)} /> + void openUrl(LEGAL_URLS.support)} /> + ); } +function AboutLink({ label, onPress }: { label: string; onPress: () => void }) { + const theme = useTheme(); + return ( + [ + styles.aboutLink, + { borderColor: theme.colors.divider }, + pressed && { opacity: 0.65 }, + ]} + > + + {label} + + + ); +} + function Section({ title, theme, @@ -581,4 +632,21 @@ const styles = StyleSheet.create({ lineHeight: 22, marginBottom: spacing.md, }, + aboutLinks: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + marginTop: spacing.xs, + }, + aboutLink: { + alignItems: 'center', + borderRadius: 16, + borderWidth: StyleSheet.hairlineWidth, + minHeight: 34, + justifyContent: 'center', + paddingHorizontal: spacing.md, + }, + aboutLinkText: { + fontWeight: '600', + }, }); diff --git a/app/note/[id].tsx b/app/note/[id].tsx index fdff6c5..080a1a4 100644 --- a/app/note/[id].tsx +++ b/app/note/[id].tsx @@ -12,7 +12,9 @@ import { Keyboard, StyleSheet, View, + useWindowDimensions, } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import * as Haptics from 'expo-haptics'; @@ -53,12 +55,14 @@ import { type PickedImageResult, } from '../../src/services/imageInsertStorage'; import { exportNotebookAsPdf } from '../../src/services/exportService'; +import { recordReviewSignal } from '../../src/services/reviewPromptService'; import { textBoxId, insertedElementId } from '../../src/utils/id'; import type { NoteMetadata } from '../../src/types/note'; import type { ToolDescriptor } from '../../src/utils/toolPalette'; const PAGE_WIDTH = 820; const PAGE_HEIGHT = 1061; +const FINGER_DRAWING_PREF_KEY = 'opennotes.editor.fingerDrawingEnabled'; type EditorAction = | { kind: 'insertImage' } @@ -112,10 +116,15 @@ function mergePreviewIntoPage( }; } +function defaultFingerDrawingForViewport(width: number, height: number): boolean { + return Math.min(width, height) < 768; +} + export default function NoteScreen() { const theme = useTheme(); const router = useRouter(); const insets = useSafeAreaInsets(); + const { width: viewportWidth, height: viewportHeight } = useWindowDimensions(); const { id } = useLocalSearchParams<{ id: string }>(); const canvasRef = useRef(null); @@ -123,6 +132,7 @@ export default function NoteScreen() { const canvasReadyRef = useRef(false); const isMountedRef = useRef(true); const navigatingRef = useRef(false); + const fingerDrawingPrefLoadedRef = useRef(false); const storedPreviewByPageIdRef = useRef(new Map()); const lastPenToolRef = useRef<'pen' | 'highlighter' | 'crayon' | 'calligraphy'>('pen'); @@ -143,6 +153,13 @@ export default function NoteScreen() { const [action, setAction] = useState(null); const [isExporting, setIsExporting] = useState(false); const [isPageSidebarOpen, setIsPageSidebarOpen] = useState(false); + const defaultFingerDrawingEnabled = useMemo( + () => defaultFingerDrawingForViewport(viewportWidth, viewportHeight), + [viewportHeight, viewportWidth], + ); + const [fingerDrawingEnabled, setFingerDrawingEnabled] = useState( + defaultFingerDrawingEnabled, + ); const [toolPopover, setToolPopover] = useState<{ descriptor: ToolDescriptor; anchor: ToolbarButtonAnchor; @@ -188,6 +205,7 @@ export default function NoteScreen() { pages: mergedPages, }; await saveNoteBody(id, merged); + void recordReviewSignal('note_saved'); }, [id, mergeStoredPreviews, rememberPagePreviews]); const [autosaveEnabled, setAutosaveEnabled] = useState(true); @@ -209,6 +227,38 @@ export default function NoteScreen() { }; }, []); + useEffect(() => { + let cancelled = false; + AsyncStorage.getItem(FINGER_DRAWING_PREF_KEY) + .then((raw) => { + if (cancelled || !isMountedRef.current) return; + if (raw === 'true' || raw === 'false') { + setFingerDrawingEnabled(raw === 'true'); + } else { + setFingerDrawingEnabled(defaultFingerDrawingEnabled); + } + }) + .catch((error) => { + if (__DEV__) console.warn('[NoteScreen] finger drawing pref load failed', error); + }) + .finally(() => { + if (!cancelled) fingerDrawingPrefLoadedRef.current = true; + }); + return () => { + cancelled = true; + }; + }, [defaultFingerDrawingEnabled]); + + useEffect(() => { + if (!fingerDrawingPrefLoadedRef.current) return; + AsyncStorage.setItem( + FINGER_DRAWING_PREF_KEY, + fingerDrawingEnabled ? 'true' : 'false', + ).catch((error) => { + if (__DEV__) console.warn('[NoteScreen] finger drawing pref save failed', error); + }); + }, [fingerDrawingEnabled]); + // Initial load useEffect(() => { let cancelled = false; @@ -603,6 +653,8 @@ export default function NoteScreen() { 'Export failed', result.error ?? 'Could not generate a PDF. Please try again.', ); + } else { + void recordReviewSignal('note_exported'); } } catch (error) { if (__DEV__) console.warn('[NoteScreen] export failed', error); @@ -655,7 +707,6 @@ export default function NoteScreen() { }, [handleBack]), ); - const allowFingerDrawing = useMemo(() => false, []); const headerHeight = insets.top + EDITOR_HEADER_BAR_HEIGHT; const pagesForOverlay = useMemo(() => { @@ -711,7 +762,7 @@ export default function NoteScreen() { toolState={toolState} backgroundType={metadata?.backgroundType ?? 'plain'} pdfBackgroundBaseUri={metadata?.pdfUri ?? undefined} - fingerDrawingEnabled={allowFingerDrawing} + fingerDrawingEnabled={fingerDrawingEnabled} onReady={handleCanvasReady} onDrawingChange={handleDrawingChange} onCurrentPageChange={handleCurrentPageChange} @@ -743,6 +794,11 @@ export default function NoteScreen() { activeTool={toolState.toolType as ToolDescriptor['type']} toolColors={toolColors} topInset={headerHeight} + fingerDrawingEnabled={fingerDrawingEnabled} + onToggleFingerDrawing={() => { + void Haptics.selectionAsync(); + setFingerDrawingEnabled((value) => !value); + }} onToolPress={handleToolPress} onToolLongPress={handleToolLongPress} onUndo={handleUndo} @@ -816,5 +872,5 @@ export default function NoteScreen() { const styles = StyleSheet.create({ flex: { flex: 1 }, center: { alignItems: 'center', justifyContent: 'center' }, - canvasArea: { flex: 1, position: 'relative' }, + canvasArea: { flex: 1, overflow: 'hidden', position: 'relative' }, }); diff --git a/assets/adaptive-icon.png b/assets/adaptive-icon.png new file mode 100644 index 0000000..5035f94 Binary files /dev/null and b/assets/adaptive-icon.png differ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..5035f94 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/splash-icon.png b/assets/splash-icon.png new file mode 100644 index 0000000..5035f94 Binary files /dev/null and b/assets/splash-icon.png differ diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..1503fa3 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,25 @@ + + + + + + OpenNotes + + + +
+

OpenNotes

+

No-bloat, local-first notes.

+

+ OpenNotes is a free, open-source notes app built around privacy, + local storage, and a focused writing experience. +

+ +
+ + diff --git a/docs/privacy/index.html b/docs/privacy/index.html new file mode 100644 index 0000000..20e9d9f --- /dev/null +++ b/docs/privacy/index.html @@ -0,0 +1,76 @@ + + + + + + OpenNotes Privacy Policy + + + +
+

OpenNotes

+

Privacy Policy

+

Last updated: May 20, 2026

+ +

+ OpenNotes is a local-first notes app published by BuilderPro LLC. The + app is designed so your notes stay on your device. +

+ +

Data We Collect

+

+ OpenNotes does not collect analytics, tracking identifiers, account + information, contact information, payment information, location data, + or the contents of your notes. +

+ +

Local Notes And Files

+

+ Notes, handwriting data, imported PDFs, images, folders, thumbnails, + and app settings are stored locally on your device. They do not leave + your device through OpenNotes unless you choose to export, share, + import, back up, or otherwise move files using your operating system or + another app. +

+ +

Permissions

+

+ OpenNotes may request access to your photo library or camera so you can + insert images into notes. OpenNotes may also use document/file access so + you can import PDFs and export notebooks. These permissions are used + only for the features you choose to use. +

+ +

Third-Party Services

+

+ OpenNotes does not include advertising SDKs, analytics SDKs, tracking + SDKs, account services, or cloud sync services. If you use operating + system services or other apps to share, back up, or store exported + files, those services are governed by their own privacy practices. +

+ +

Children's Privacy

+

+ OpenNotes does not knowingly collect personal information from anyone, + including children. +

+ +

Changes

+

+ If this policy changes, the updated version will be posted on this + page with a new effective date. +

+ +

Contact

+

+ Questions or requests can be sent to + Mark on X or opened on + GitHub. + Security issues should be reported privately to + mark@builderproapps.com. +

+ +

Back to OpenNotes

+
+ + diff --git a/docs/styles.css b/docs/styles.css new file mode 100644 index 0000000..8073858 --- /dev/null +++ b/docs/styles.css @@ -0,0 +1,107 @@ +:root { + color-scheme: light dark; + --bg: #f7f7f4; + --text: #171717; + --muted: #626262; + --line: #deded8; + --accent: #1769e0; + --panel: #ffffff; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f0f10; + --text: #f5f5f2; + --muted: #a7a7a0; + --line: #2d2d2d; + --accent: #66a6ff; + --panel: #181819; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.55; +} + +.page { + width: min(760px, calc(100% - 40px)); + margin: 0 auto; + padding: 80px 0; +} + +.document { + padding-top: 56px; +} + +.eyebrow { + margin: 0 0 14px; + color: var(--muted); + font-size: 0.84rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1 { + margin: 0 0 18px; + font-size: clamp(2.4rem, 7vw, 4.8rem); + line-height: 0.95; + letter-spacing: -0.03em; +} + +.document h1 { + font-size: clamp(2rem, 5vw, 3.4rem); +} + +h2 { + margin: 34px 0 10px; + font-size: 1.15rem; +} + +p { + margin: 0 0 18px; +} + +.lede { + max-width: 620px; + color: var(--muted); + font-size: 1.18rem; +} + +.updated { + color: var(--muted); +} + +a { + color: var(--accent); + text-decoration-thickness: 1px; + text-underline-offset: 3px; +} + +.links { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 30px; +} + +.links a { + display: inline-flex; + align-items: center; + min-height: 42px; + padding: 0 16px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel); + color: var(--text); + font-weight: 650; + text-decoration: none; +} diff --git a/docs/support/index.html b/docs/support/index.html new file mode 100644 index 0000000..b12bf92 --- /dev/null +++ b/docs/support/index.html @@ -0,0 +1,25 @@ + + + + + + OpenNotes Support + + + +
+

OpenNotes

+

Support

+

+ For bugs, feature requests, and support questions, use the public + GitHub issue tracker or contact Mark on X. +

+ +
+ + diff --git a/docs/terms/index.html b/docs/terms/index.html new file mode 100644 index 0000000..01a94a0 --- /dev/null +++ b/docs/terms/index.html @@ -0,0 +1,66 @@ + + + + + + OpenNotes Terms of Use + + + +
+

OpenNotes

+

Terms of Use

+

Last updated: May 20, 2026

+ +

+ OpenNotes is a free, open-source notes app published by BuilderPro LLC. + By using the app, you agree to these terms. +

+ +

Use Of The App

+

+ You are responsible for the notes, PDFs, images, exports, and other + content you create, import, store, or share with OpenNotes. Use the app + only in ways that comply with applicable laws and the rights of others. +

+ +

Local Storage And Backups

+

+ OpenNotes stores notes locally on your device. You are responsible for + backing up important notes and exported files. Deleting the app, + deleting local data, device failure, or operating-system behavior may + remove locally stored content. +

+ +

Open Source

+

+ OpenNotes is distributed as open-source software under the license + included in the repository. These terms do not limit rights granted by + the open-source license for the app's source code. +

+ +

No Warranty

+

+ OpenNotes is provided as is and as available, without warranties of any + kind. To the fullest extent permitted by law, BuilderPro LLC is not + liable for lost notes, lost files, lost data, or indirect damages + arising from use of the app. +

+ +

Changes

+

+ These terms may be updated from time to time. The updated version will + be posted on this page with a new effective date. +

+ +

Contact

+

+ Questions can be sent to + Mark on X or opened on + GitHub. +

+ +

Back to OpenNotes

+
+ + diff --git a/ios/OpenNotes.xcodeproj/project.pbxproj b/ios/OpenNotes.xcodeproj/project.pbxproj index 6a2db26..59c49fe 100644 --- a/ios/OpenNotes.xcodeproj/project.pbxproj +++ b/ios/OpenNotes.xcodeproj/project.pbxproj @@ -354,7 +354,7 @@ CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = U2CPXQV7AJ; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -374,7 +374,7 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; - PRODUCT_BUNDLE_IDENTIFIER = com.opennotes.app; + PRODUCT_BUNDLE_IDENTIFIER = com.builderpro.opennotes; PRODUCT_NAME = OpenNotes; SWIFT_OBJC_BRIDGING_HEADER = "OpenNotes/OpenNotes-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -393,7 +393,7 @@ CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = U2CPXQV7AJ; INFOPLIST_FILE = OpenNotes/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -408,7 +408,7 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; - PRODUCT_BUNDLE_IDENTIFIER = com.opennotes.app; + PRODUCT_BUNDLE_IDENTIFIER = com.builderpro.opennotes; PRODUCT_NAME = OpenNotes; SWIFT_OBJC_BRIDGING_HEADER = "OpenNotes/OpenNotes-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/OpenNotes/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/ios/OpenNotes/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png index ac881f6..5035f94 100644 Binary files a/ios/OpenNotes/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png and b/ios/OpenNotes/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png differ diff --git a/ios/OpenNotes/Info.plist b/ios/OpenNotes/Info.plist index 77b8dfb..b52916e 100644 --- a/ios/OpenNotes/Info.plist +++ b/ios/OpenNotes/Info.plist @@ -34,7 +34,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 0.1.0 + 1.0 CFBundleSignature ???? CFBundleURLTypes @@ -43,7 +43,7 @@ CFBundleURLSchemes opennotes - com.opennotes.app + com.builderpro.opennotes @@ -54,7 +54,7 @@ CFBundleVersion - 1 + 6 LSMinimumSystemVersion 12.0 LSRequiresIPhoneOS diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 49dcf32..4352aad 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -249,6 +249,8 @@ PODS: - ExpoModulesCore - ExpoSharing (14.0.8): - ExpoModulesCore + - ExpoStoreReview (9.0.9): + - ExpoModulesCore - EXUpdatesInterface (2.0.0): - ExpoModulesCore - FBLazyVector (0.81.5) @@ -2240,6 +2242,7 @@ DEPENDENCIES: - ExpoModulesCore (from `../node_modules/expo-modules-core`) - ExpoPrint (from `../node_modules/expo-print/ios`) - ExpoSharing (from `../node_modules/expo-sharing/ios`) + - ExpoStoreReview (from `../node_modules/expo-store-review/ios`) - EXUpdatesInterface (from `../node_modules/expo-updates-interface/ios`) - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) @@ -2361,6 +2364,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-print/ios" ExpoSharing: :path: "../node_modules/expo-sharing/ios" + ExpoStoreReview: + :path: "../node_modules/expo-store-review/ios" EXUpdatesInterface: :path: "../node_modules/expo-updates-interface/ios" FBLazyVector: @@ -2537,6 +2542,7 @@ SPEC CHECKSUMS: ExpoModulesCore: 9e6a5514828e7dd5ded9e99d33557be2d284e660 ExpoPrint: 813bfa965eda2ecc4b1cdc61612c820e239c382a ExpoSharing: 0d983394ed4a80334bab5a0d5384f75710feb7e8 + ExpoStoreReview: 32bb43b6fae9c8db3e33cad69996dff3785eef5f EXUpdatesInterface: 5adf50cb41e079c861da6d9b4b954c3db9a50734 FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 diff --git a/package-lock.json b/package-lock.json index e52cfb5..7167a65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-notes", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-notes", - "version": "0.1.0", + "version": "1.0.0", "license": "Apache-2.0", "dependencies": { "@expo/vector-icons": "^15.0.3", @@ -25,6 +25,7 @@ "expo-router": "~6.0.23", "expo-sharing": "~14.0.8", "expo-status-bar": "~3.0.9", + "expo-store-review": "~9.0.9", "react": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "2.28.0", @@ -5229,6 +5230,16 @@ "react-native": "*" } }, + "node_modules/expo-store-review": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/expo-store-review/-/expo-store-review-9.0.9.tgz", + "integrity": "sha512-99vS7edXlKzPcdjrzVlMQWc4zOyq4khQfFjhNqJgpGP+AgRn4U0LaZkHIrVjmzolryD3rcHJSiUQH9Vi0sD0MQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-updates-interface": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz", diff --git a/package.json b/package.json index 03be57a..cf297ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-notes", - "version": "0.1.0", + "version": "1.0.0", "description": "A no-bloat, local-first, open-source notes app powered by Mobile Ink.", "private": true, "license": "Apache-2.0", @@ -36,6 +36,7 @@ "expo-router": "~6.0.23", "expo-sharing": "~14.0.8", "expo-status-bar": "~3.0.9", + "expo-store-review": "~9.0.9", "react": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "2.28.0", diff --git a/src/components/editor/FloatingToolbar.tsx b/src/components/editor/FloatingToolbar.tsx index 5f7a295..e5b9b75 100644 --- a/src/components/editor/FloatingToolbar.tsx +++ b/src/components/editor/FloatingToolbar.tsx @@ -27,7 +27,7 @@ const TOOLBAR_PAD = 6; const EDGE_MARGIN = 12; const DRAG_ACTIVATE_DISTANCE = 8; const BUTTON_FRAME = 38; -const ACTION_BUTTON_COUNT = 2; +const ACTION_BUTTON_COUNT = 3; const DIVIDER_FRAME = spacing.xs * 2 + StyleSheet.hairlineWidth; const ESTIMATED_HORIZONTAL_WIDTH = TOOLBAR_PAD * 2 + @@ -178,6 +178,8 @@ export interface FloatingToolbarProps { topInset: number; onToolPress: (tool: ToolDescriptor, alreadyActive: boolean, anchor: ToolbarButtonAnchor) => void; onToolLongPress: (tool: ToolDescriptor, anchor: ToolbarButtonAnchor) => void; + fingerDrawingEnabled: boolean; + onToggleFingerDrawing: () => void; onUndo: () => void; onRedo: () => void; } @@ -197,6 +199,8 @@ export function FloatingToolbar({ topInset, onToolPress, onToolLongPress, + fingerDrawingEnabled, + onToggleFingerDrawing, onUndo, onRedo, }: FloatingToolbarProps) { @@ -207,6 +211,7 @@ export function FloatingToolbar({ const [dockedEdge, setDockedEdge] = useState('top'); const orientation = orientationForEdge(dockedEdge); const isVertical = orientation === 'vertical'; + const isCompactHorizontal = !isVertical && screenWidth < 430; const dockedEdgeRef = useRef(dockedEdge); const draggingRef = useRef(false); const pillLayoutRef = useRef({ @@ -460,6 +465,7 @@ export function FloatingToolbar({ style={[ styles.pill, isVertical ? styles.pillVertical : styles.pillHorizontal, + isCompactHorizontal && styles.pillCompact, { backgroundColor: theme.colors.toolbarBackground, borderColor: theme.colors.toolbarBorder, @@ -487,6 +493,7 @@ export function FloatingToolbar({ descriptor={tool} active={isActive} color={tint} + compact={isCompactHorizontal} onPress={() => { void Haptics.selectionAsync(); onToolPress(tool, isActive, anchorFor(tool.type)); @@ -508,8 +515,30 @@ export function FloatingToolbar({ ]} /> - - + + + @@ -519,10 +548,16 @@ export function FloatingToolbar({ function SmallIconButton({ iconName, + active = false, + compact = false, + accessibilityLabel, onPress, theme, }: { iconName: keyof typeof Ionicons.glyphMap; + active?: boolean; + compact?: boolean; + accessibilityLabel: string; onPress: () => void; theme: ReturnType; }) { @@ -533,12 +568,26 @@ function SmallIconButton({ onPress(); }} hitSlop={4} + accessibilityRole="button" + accessibilityLabel={accessibilityLabel} + accessibilityState={{ selected: active }} style={({ pressed }) => [ styles.smallButton, - { backgroundColor: pressed ? theme.colors.surfaceMuted : 'transparent' }, + compact && styles.smallButtonCompact, + { + backgroundColor: active + ? theme.colors.accentMuted + : pressed + ? theme.colors.surfaceMuted + : 'transparent', + }, ]} > - + ); } @@ -565,6 +614,10 @@ const styles = StyleSheet.create({ shadowRadius: 12, elevation: 8, }, + pillCompact: { + paddingHorizontal: 4, + paddingVertical: 4, + }, pillHorizontal: { flexDirection: 'row', }, @@ -589,4 +642,8 @@ const styles = StyleSheet.create({ borderRadius: radius.md, margin: 1, }, + smallButtonCompact: { + width: 30, + height: 30, + }, }); diff --git a/src/components/editor/ImageInsertOverlay.tsx b/src/components/editor/ImageInsertOverlay.tsx index 203c578..5063720 100644 --- a/src/components/editor/ImageInsertOverlay.tsx +++ b/src/components/editor/ImageInsertOverlay.tsx @@ -10,6 +10,7 @@ import { Ionicons } from '@expo/vector-icons'; import { Gesture, GestureDetector, + PointerType, } from 'react-native-gesture-handler'; import Animated, { runOnJS, @@ -310,12 +311,12 @@ export function ImageInsertOverlay({ ]); const dragGesture = Gesture.Pan() - .enabled(!isCropMode) + .enabled(isSelected && !isCropMode) .manualActivation(true) .minDistance(2) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === 1) { + if (event.pointerType === PointerType.STYLUS) { stateManager.fail(); return; } @@ -361,7 +362,7 @@ export function ImageInsertOverlay({ .maxDuration(450) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === 1) { + if (event.pointerType === PointerType.STYLUS) { stateManager.fail(); } }) @@ -375,7 +376,7 @@ export function ImageInsertOverlay({ .manualActivation(true) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === 1) { + if (event.pointerType === PointerType.STYLUS) { stateManager.fail(); return; } @@ -413,7 +414,7 @@ export function ImageInsertOverlay({ .manualActivation(true) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === 1) { + if (event.pointerType === PointerType.STYLUS) { stateManager.fail(); return; } @@ -757,7 +758,7 @@ function CropHandles({ .manualActivation(true) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === 1) { + if (event.pointerType === PointerType.STYLUS) { stateManager.fail(); return; } diff --git a/src/components/editor/OverlayLayer.tsx b/src/components/editor/OverlayLayer.tsx index e355b46..9b8a4e6 100644 --- a/src/components/editor/OverlayLayer.tsx +++ b/src/components/editor/OverlayLayer.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { GestureResponderEvent, + Keyboard, Pressable, StyleSheet, View, @@ -125,12 +126,13 @@ export function OverlayLayer({ const handleBlankTap = useCallback( (event: GestureResponderEvent) => { - if (activeTool === 'text') { - handleTextToolTap(event); - return; - } if (selection) { + Keyboard.dismiss(); onSelectionChange(null); + return; + } + if (activeTool === 'text') { + handleTextToolTap(event); } }, [activeTool, handleTextToolTap, onSelectionChange, selection], @@ -287,6 +289,6 @@ export function OverlayLayer({ const styles = StyleSheet.create({ pageHost: { position: 'absolute', - overflow: 'visible', + overflow: 'hidden', }, }); diff --git a/src/components/editor/TextBoxOverlay.tsx b/src/components/editor/TextBoxOverlay.tsx index fe9ac38..17632cf 100644 --- a/src/components/editor/TextBoxOverlay.tsx +++ b/src/components/editor/TextBoxOverlay.tsx @@ -12,6 +12,7 @@ import { Ionicons } from '@expo/vector-icons'; import { Gesture, GestureDetector, + PointerType, } from 'react-native-gesture-handler'; import Animated, { runOnJS, @@ -219,12 +220,12 @@ export function TextBoxOverlay({ ); const dragGesture = Gesture.Pan() - .enabled(!isEditing) + .enabled(isSelected && !isEditing) .manualActivation(true) .minDistance(2) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === 1) { + if (event.pointerType === PointerType.STYLUS) { stateManager.fail(); return; } @@ -266,7 +267,7 @@ export function TextBoxOverlay({ .maxDuration(450) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === 1) { + if (event.pointerType === PointerType.STYLUS) { stateManager.fail(); } }) @@ -277,10 +278,11 @@ export function TextBoxOverlay({ const createResizeGesture = (handle: ResizeHandle) => Gesture.Pan() + .enabled(isSelected && !isEditing) .manualActivation(true) .onTouchesDown((event, stateManager) => { 'worklet'; - if (event.pointerType === 1) { + if (event.pointerType === PointerType.STYLUS) { stateManager.fail(); return; } diff --git a/src/components/editor/ToolButton.tsx b/src/components/editor/ToolButton.tsx index b31725e..4368dfb 100644 --- a/src/components/editor/ToolButton.tsx +++ b/src/components/editor/ToolButton.tsx @@ -14,6 +14,7 @@ export interface ToolButtonProps { descriptor: ToolDescriptor; active: boolean; color?: string; + compact?: boolean; onPress: () => void; onLongPress?: () => void; } @@ -22,6 +23,7 @@ export function ToolButton({ descriptor, active, color, + compact = false, onPress, onLongPress, }: ToolButtonProps) { @@ -38,6 +40,7 @@ export function ToolButton({ hitSlop={6} style={({ pressed }) => [ styles.button, + compact && styles.buttonCompact, { backgroundColor: active ? theme.colors.accentMuted @@ -50,11 +53,17 @@ export function ToolButton({ active && styles.buttonActive, ]} > - + {descriptor.supportsColor && color ? ( ; + return ; case 'material': return ( ); @@ -150,12 +161,12 @@ function ToolIcon({ return ( ); case 'feather': - return ; + return ; } } @@ -168,6 +179,11 @@ const styles = StyleSheet.create({ borderRadius: radius.lg, margin: 1, }, + buttonCompact: { + width: 30, + height: 30, + borderRadius: radius.md, + }, buttonActive: { shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.16, @@ -181,6 +197,11 @@ const styles = StyleSheet.create({ borderRadius: 3, borderWidth: StyleSheet.hairlineWidth, }, + colorPipCompact: { + bottom: 2, + width: 11, + height: 3, + }, }); export { ToolIcon }; diff --git a/src/services/reviewPromptService.ts b/src/services/reviewPromptService.ts new file mode 100644 index 0000000..a1c8024 --- /dev/null +++ b/src/services/reviewPromptService.ts @@ -0,0 +1,98 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as StoreReview from 'expo-store-review'; + +type ReviewSignal = 'note_created' | 'note_opened' | 'note_saved' | 'note_exported'; + +interface ReviewPromptState { + firstSeenAt: string; + lastPromptedAt: string | null; + promptCount: number; + notesCreated: number; + notesOpened: number; + notesSaved: number; + notesExported: number; + pendingPositiveMoment: boolean; +} + +const KEY = '@opennotes:reviewPrompt:v1'; +const MIN_SESSION_AGE_MS = 15 * 60 * 1000; +const PROMPT_COOLDOWN_MS = 120 * 24 * 60 * 60 * 1000; +const MAX_PROMPTS = 3; + +function initialState(now: string): ReviewPromptState { + return { + firstSeenAt: now, + lastPromptedAt: null, + promptCount: 0, + notesCreated: 0, + notesOpened: 0, + notesSaved: 0, + notesExported: 0, + pendingPositiveMoment: false, + }; +} + +async function readState(): Promise { + const now = new Date().toISOString(); + const raw = await AsyncStorage.getItem(KEY); + if (!raw) return initialState(now); + try { + return { ...initialState(now), ...(JSON.parse(raw) as Partial) }; + } catch { + return initialState(now); + } +} + +async function writeState(state: ReviewPromptState): Promise { + await AsyncStorage.setItem(KEY, JSON.stringify(state)); +} + +export async function recordReviewSignal(signal: ReviewSignal): Promise { + const state = await readState(); + switch (signal) { + case 'note_created': + state.notesCreated += 1; + break; + case 'note_opened': + state.notesOpened += 1; + break; + case 'note_saved': + state.notesSaved += 1; + break; + case 'note_exported': + state.notesExported += 1; + break; + } + state.pendingPositiveMoment = true; + await writeState(state); +} + +export async function requestReviewAfterPositiveMoment(): Promise { + const state = await readState(); + if (!state.pendingPositiveMoment || state.promptCount >= MAX_PROMPTS) return; + + const now = Date.now(); + const firstSeen = Date.parse(state.firstSeenAt); + const lastPrompted = state.lastPromptedAt ? Date.parse(state.lastPromptedAt) : 0; + if (Number.isFinite(firstSeen) && now - firstSeen < MIN_SESSION_AGE_MS) return; + if (lastPrompted && now - lastPrompted < PROMPT_COOLDOWN_MS) return; + + const steadyUse = state.notesSaved >= 5 && state.notesOpened >= 3; + const creatorUse = state.notesCreated >= 2 && state.notesSaved >= 3; + const exportSuccess = state.notesExported >= 1 && state.notesSaved >= 2; + if (!steadyUse && !creatorUse && !exportSuccess) return; + + const available = await StoreReview.isAvailableAsync(); + if (!available) return; + + const hasAction = await StoreReview.hasAction(); + if (!hasAction) return; + + await StoreReview.requestReview(); + await writeState({ + ...state, + pendingPositiveMoment: false, + promptCount: state.promptCount + 1, + lastPromptedAt: new Date().toISOString(), + }); +}