diff --git a/README.md b/README.md index baa9e17..6def45e 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,24 @@ This first release focuses on the local-first teaching workflow: database - English and German translations through `easy_localization` +## Data storage + +Classi stores your library in a `.classi` folder that you choose during the +first-run setup. The setup wizard always requires an explicit folder selection +so your data is never silently placed inside an app-private directory. + +**Recommended locations:** + +| Platform | Recommended folder | +|---|---| +| Android | A folder in shared storage such as *Documents* — **not** inside `Android/data/`, which is erased when the app is uninstalled | +| macOS (App Store) | `~/Documents/Classi` or another location outside `~/Library/Containers/` | +| macOS / Windows / Linux | Any folder in your home directory or an accessible drive | + +Regardless of platform, enabling **auto-export backups** in Settings (or during +setup) is strongly recommended so you always have a portable `.classi-backup` +file in a separate location. + ## Local development Linux desktop builds need native packages installed first: diff --git a/assets/translations/de.json b/assets/translations/de.json index 69c9c46..0336832 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -236,6 +236,9 @@ "app_version": "App-Version", "build_commit": "Build-Commit", "library_package_required": "Wähle einen .classi-Bibliotheksordner aus.", + "library_folder_required": "Bitte wähle einen Ordner für deine Bibliothek aus.", + "library_storage_warning_android": "Wähle einen Ordner im freigegebenen Speicher (z. B. Dokumente), damit deine Daten bei einer Neuinstallation der App erhalten bleiben.", + "no_folder_selected": "Kein Ordner ausgewählt", "biometric_unlock": "Biometrisch entsperren", "biometric_unlock_hint": "Fingerabdruck oder Gesichtserkennung statt Passphrase verwenden.", "biometric_reason": "Bestätige deine Identität, um Classi zu entsperren.", diff --git a/assets/translations/en.json b/assets/translations/en.json index 670ef37..dda5e5a 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -236,6 +236,9 @@ "app_version": "App version", "build_commit": "Build commit", "library_package_required": "Choose a .classi library folder.", + "library_folder_required": "Please choose a folder for your library.", + "library_storage_warning_android": "Choose a folder in shared storage (such as Documents) so your data is preserved if the app is reinstalled.", + "no_folder_selected": "No folder chosen", "biometric_unlock": "Unlock with biometrics", "biometric_unlock_hint": "Use fingerprint or face recognition instead of your passphrase.", "biometric_reason": "Confirm your identity to unlock Classi.", diff --git a/lib/features/setup/setup_screen.dart b/lib/features/setup/setup_screen.dart index 4613b8b..d7a3979 100644 --- a/lib/features/setup/setup_screen.dart +++ b/lib/features/setup/setup_screen.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:easy_localization/easy_localization.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -21,6 +23,7 @@ class SetupScreen extends ConsumerStatefulWidget { class _SetupScreenState extends ConsumerState { static const int _totalSteps = 4; + static const String _defaultLibraryName = 'classi'; static const Map _timeoutOptions = { 1: Duration(minutes: 1), @@ -38,6 +41,7 @@ class _SetupScreenState extends ConsumerState { int _currentStep = 0; bool _isSaving = false; String? _selectedFolder; + bool _folderError = false; // Step 3: App lock preferences with recommended defaults. bool _lockOnBackground = true; @@ -64,20 +68,21 @@ class _SetupScreenState extends ConsumerState { } Future _loadDefaultPath() async { - final currentPath = - await ref.read(appSessionProvider).currentDatabasePath(); + // Do not pre-populate the folder so the user must explicitly choose where + // to store their data. Only pre-fill the library name with a sensible + // default so the name field is not blank. if (!mounted) return; setState(() { - _selectedFolder = - DatabasePathService.containerParentPathFor(currentPath); - _nameController.text = p.basenameWithoutExtension(currentPath); + _selectedFolder = null; + _nameController.text = _defaultLibraryName; }); } String get _databasePath { - final folder = _selectedFolder ?? ''; + final folder = _selectedFolder; + if (folder == null) return ''; final name = _nameController.text.trim().isEmpty - ? 'classi' + ? _defaultLibraryName : _nameController.text.trim(); return p.join( folder, @@ -96,6 +101,9 @@ class _SetupScreenState extends ConsumerState { Future _onNext() async { switch (_currentStep) { case 0: + final folderMissing = _selectedFolder == null; + setState(() => _folderError = folderMissing); + if (folderMissing) return; if (_locationFormKey.currentState!.validate()) { setState(() => _currentStep = 1); } @@ -163,7 +171,10 @@ class _SetupScreenState extends ConsumerState { dialogTitle: 'choose_library_folder'.tr(), ); if (folder != null && mounted) { - setState(() => _selectedFolder = folder); + setState(() { + _selectedFolder = folder; + _folderError = false; + }); } } @@ -275,6 +286,7 @@ class _SetupScreenState extends ConsumerState { isSaving: _isSaving, onPickFolder: _pickFolder, onChanged: () => setState(() {}), + folderError: _folderError, ); case 1: return _SecurityStep( @@ -354,6 +366,7 @@ class _LibraryStep extends StatelessWidget { required this.isSaving, required this.onPickFolder, required this.onChanged, + required this.folderError, }); final GlobalKey formKey; @@ -363,9 +376,11 @@ class _LibraryStep extends StatelessWidget { final bool isSaving; final VoidCallback onPickFolder; final VoidCallback onChanged; + final bool folderError; @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; return Form( key: formKey, child: Column( @@ -382,9 +397,16 @@ class _LibraryStep extends StatelessWidget { children: [ Expanded( child: Text( - selectedFolder ?? '', + selectedFolder ?? 'no_folder_selected'.tr(), overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: selectedFolder == null + ? colorScheme.onSurfaceVariant + : null, + fontStyle: selectedFolder == null + ? FontStyle.italic + : FontStyle.normal, + ), ), ), IconButton( @@ -394,6 +416,19 @@ class _LibraryStep extends StatelessWidget { ), ], ), + if (folderError) ...[ + const SizedBox(height: 4), + Text( + 'library_folder_required'.tr(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + ], + if (Platform.isAndroid) ...[ + const SizedBox(height: 8), + const _AndroidStorageNote(), + ], const SizedBox(height: 12), TextFormField( controller: nameController, @@ -410,11 +445,13 @@ class _LibraryStep extends StatelessWidget { }, onChanged: (_) => onChanged(), ), - const SizedBox(height: 8), - SelectableText( - databasePath, - style: Theme.of(context).textTheme.bodySmall, - ), + if (databasePath.isNotEmpty) ...[ + const SizedBox(height: 8), + SelectableText( + databasePath, + style: Theme.of(context).textTheme.bodySmall, + ), + ], const SizedBox(height: 16), const AutoImportPromptCard(), ], @@ -423,6 +460,31 @@ class _LibraryStep extends StatelessWidget { } } +class _AndroidStorageNote extends StatelessWidget { + const _AndroidStorageNote(); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 16, + color: Theme.of(context).colorScheme.secondary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'library_storage_warning_android'.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ); + } +} + class _SecurityStep extends StatelessWidget { const _SecurityStep({ required this.formKey,