Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions assets/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
3 changes: 3 additions & 0 deletions assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
92 changes: 77 additions & 15 deletions lib/features/setup/setup_screen.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,6 +23,7 @@ class SetupScreen extends ConsumerStatefulWidget {

class _SetupScreenState extends ConsumerState<SetupScreen> {
static const int _totalSteps = 4;
static const String _defaultLibraryName = 'classi';

static const Map<int, Duration> _timeoutOptions = {
1: Duration(minutes: 1),
Expand All @@ -38,6 +41,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
int _currentStep = 0;
bool _isSaving = false;
String? _selectedFolder;
bool _folderError = false;

// Step 3: App lock preferences with recommended defaults.
bool _lockOnBackground = true;
Expand All @@ -64,20 +68,21 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}

Future<void> _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,
Expand All @@ -96,6 +101,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<void> _onNext() async {
switch (_currentStep) {
case 0:
final folderMissing = _selectedFolder == null;
setState(() => _folderError = folderMissing);
if (folderMissing) return;
if (_locationFormKey.currentState!.validate()) {
setState(() => _currentStep = 1);
}
Expand Down Expand Up @@ -163,7 +171,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
dialogTitle: 'choose_library_folder'.tr(),
);
if (folder != null && mounted) {
setState(() => _selectedFolder = folder);
setState(() {
_selectedFolder = folder;
_folderError = false;
});
}
}

Expand Down Expand Up @@ -275,6 +286,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
isSaving: _isSaving,
onPickFolder: _pickFolder,
onChanged: () => setState(() {}),
folderError: _folderError,
);
case 1:
return _SecurityStep(
Expand Down Expand Up @@ -354,6 +366,7 @@ class _LibraryStep extends StatelessWidget {
required this.isSaving,
required this.onPickFolder,
required this.onChanged,
required this.folderError,
});

final GlobalKey<FormState> formKey;
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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(),
],
Expand All @@ -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,
Expand Down