Build a Cross-Platform File Notes Vault

Build a Flutter notes app with full file CRUD across app-scoped and user-chosen storage.

Introduction

30 Second Summary

Every app on your phone stores data somewhere. Some files disappear the moment you uninstall the app. Others stick around forever in folders you chose yourself.

In this project, you will build a Flutter app called "Notes Vault" that stores text notes in two different locations, teaching you how mobile file storage and permissions actually work under the hood.

What You'll Build

A cross-platform Flutter notes app with two storage modes: private app-scoped storage and user-chosen directory storage via native file pickers.

By the end of this project, you'll have:

  • A working Flutter app with full CRUD operations (create, read, edit, delete) on text files stored in two locations.
  • Hands-on understanding of Android's Storage Access Framework and iOS security-scoped URLs for accessing user-chosen directories with zero manifest permissions.
  • A clear mental model of file lifetime: app-scoped files are deleted on uninstall, while user-chosen directory files persist independently of the app.
  • Secret Mission: Persist the user's directory choice across app restarts so they never need to re-pick.

Are there any prerequisites?

This project assumes you have basic Flutter knowledge (widget composition, state management) and a Mac with the Flutter SDK, Xcode, and Android Studio already configured.

You should be able to run flutter create and launch apps on both an Android emulator and iOS simulator before starting.

Set Up the Flutter Project

Every Flutter app starts with a project scaffold. Before you can build file storage features, you need the project structure, dependencies, and a confirmed build on both target platforms.

In this step, you will create the Notes Vault project, add two packages that handle file paths and directory picking, and verify everything compiles on Android and iOS.

In this step, get ready to:
  • Create the notes_vault Flutter project.
  • Add path_provider and file_picker dependencies.
  • Confirm the app builds and runs on both Android and iOS.
Create the Flutter project

Flutter's CLI can scaffold an entire project in one command. This gives you the folder structure, platform configuration files, and a runnable starter app.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, then type Terminal and press Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Flutter project by running this command:
flutter create notes_vault

Flutter will print a list of all the files it generated. You should see a message confirming it wrote files at the end.

Seeing command not found?

Make sure the Flutter SDK is on your PATH. Run flutter doctor to confirm your installation is working.

Help me fix my Flutter PATH

  • Move into your new project folder by running:
cd notes_vault
  • Confirm the project files exist by running:
ls

You should see folders like lib, android, ios, and files like pubspec.yaml in the output.

  • Open the notes_vault folder in VS Code (or your preferred code editor). Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the notes_vault folder on your Desktop, then click Open.
  • Open lib/main.dart in the editor sidebar.
  • Select all of its contents and delete them.
  • Replace the file with this starter code:
import 'package:flutter/material.dart';

void main() {
  runApp(const NotesVaultApp());
}

class NotesVaultApp extends StatelessWidget {
  const NotesVaultApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Notes Vault',
      home: Scaffold(body: Center(child: Text('Notes Vault'))),
    );
  }
}

What does this code do?

  • NotesVaultApp is a minimal StatelessWidget that sets up a MaterialApp shell.
  • The home screen is a simple centered text label. You will replace this with the tabbed notes UI in later steps.
  • Save lib/main.dart.

Seeing red underlines in the editor?

If your editor shows errors on the import line, run flutter pub get in your terminal first. The next substep covers this.

Help me resolve missing package errors

✔️ Awesome, I've got everything!

Great. Make sure you have saved lib/main.dart before moving on.

ⓧ I'd like to double check the full code

import 'package:flutter/material.dart';

void main() {
  runApp(const NotesVaultApp());
}

class NotesVaultApp extends StatelessWidget {
  const NotesVaultApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Notes Vault',
      home: Scaffold(body: Center(child: Text('Notes Vault'))),
    );
  }
}
Add dependencies

Your app needs two packages to handle file storage. path_provider locates platform-specific directories (like the app's documents folder). file_picker lets the user choose a directory through the native system picker.

  • Open pubspec.yaml in your editor.
  • Select all of its contents and replace with the following:
name: notes_vault
description: A cross-platform file notes app demonstrating Flutter file storage and permissions.
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ">=3.4.0 <4.0.0"

dependencies:
  flutter:
    sdk: flutter
  path_provider: ^2.1.5
  file_picker: ^11.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

What does this file do?

  • path_provider: ^2.1.5 gives you getApplicationDocumentsDirectory() to find where the app can safely store private files.
  • file_picker: ^11.0.2 provides FilePicker.getDirectoryPath() which invokes the native directory picker on each platform.
  • The environment constraint ensures compatible Dart SDK versions.
  • Save pubspec.yaml.
  • Install the dependencies by running this command in your terminal:
flutter pub get

You should see output ending with a message confirming packages were resolved successfully.

Seeing dependency resolution errors?

Double-check your pubspec.yaml indentation. YAML is whitespace-sensitive. Each nested level uses exactly two spaces.

Help me fix pubspec.yaml formatting

✔️ Awesome, I've got everything!

Great. Make sure pubspec.yaml is saved and flutter pub get completed without errors.

ⓧ I'd like to double check the full code

name: notes_vault
description: A cross-platform file notes app demonstrating Flutter file storage and permissions.
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: ">=3.4.0 <4.0.0"

dependencies:
  flutter:
    sdk: flutter
  path_provider: ^2.1.5
  file_picker: ^11.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true
Verify platform configuration

A key advantage of using file_picker with the Storage Access Framework on Android and UIDocumentPickerViewController on iOS is that neither requires manifest permissions or extra entitlements.

Why no permissions needed?

On Android, file_picker uses ACTION_OPEN_DOCUMENT_TREE which grants access through the system picker UI. The user explicitly chooses the directory, so no declared permissions are needed.

On iOS, the document picker extends the app sandbox to the selected directory. No additional entitlements are required because the user controls access through the picker.

  • Launch your iOS Simulator. Run the app on iOS by running:
flutter run

What should I see?

The iOS Simulator should launch and display a white screen with the text "Notes Vault" centered on it. This confirms the app compiles and runs on iOS.

  • Stop the running app by pressing q in the terminal.
  • Launch your Android emulator from Android Studio. Open Android Studio, go to Virtual Device Manager, and start an emulator.
  • Run the app on Android by running:
flutter run

You should see the same "Notes Vault" text centered on the Android emulator screen.

Multiple devices connected?

If you have both simulator and emulator running, Flutter will ask you to choose a device. Select the Android emulator from the list.

App fails to build?

Run flutter doctor to check for missing toolchain components. Make sure Xcode command line tools are installed and your Android emulator image matches your SDK version.

Help me resolve build failures

Your project is scaffolded and running on both platforms. Next up, you will build the private notes feature that stores files inside the app's own documents directory.

Build Private Notes CRUD

Your app is set up and running on both platforms. Now it is time to make it do something useful.

In this step, you will build a complete file-based notes system that stores text files in the app's private documents directory. This directory requires zero permissions on both Android and iOS, and files stored here are automatically deleted when the user uninstalls the app.

In this step, get ready to:
  • Create the app shell with tabbed navigation for Private and Shared modes.
  • Build CRUD operations (create, read, edit, delete) for text files in app-scoped storage.
  • Test the full notes lifecycle on both Android and iOS.
Create the file service for app-scoped storage

Every mobile platform gives each app a private sandbox directory where it can read and write files freely. No permission dialogs, no user interaction needed.

The path_provider package resolves this directory for you with getApplicationDocumentsDirectory(). On Android it maps to the app's internal files directory. On iOS it maps to the app's Documents/ sandbox directory.

  • Open lib/main.dart in your editor.
  • Select all the existing code and replace it with this app shell:
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:file_picker/file_picker.dart';
import 'dart:io';

void main() {
  runApp(const NotesVaultApp());
}

// App entry point with Material 3 theming
class NotesVaultApp extends StatelessWidget {
  const NotesVaultApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Notes Vault',
      theme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
      ),
      home: const HomeScreen(),
    );
  }
}

// Tabbed home screen with Private and Shared tabs
class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen>
    with SingleTickerProviderStateMixin {
  late TabController _tabController;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 2, vsync: this);
  }

  @override
  void dispose() {
    _tabController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Notes Vault'),
        bottom: TabBar(
          controller: _tabController,
          tabs: const [
            Tab(icon: Icon(Icons.lock), text: 'Private'),
            Tab(icon: Icon(Icons.folder_open), text: 'Shared'),
          ],
        ),
      ),
      body: TabBarView(
        controller: _tabController,
        children: const [
          PrivateNotesTab(),
          SharedNotesTab(),
        ],
      ),
    );
  }
}

What does this code do?

This replaces the blank scaffold with a proper app shell.

  • The imports bring in path_provider for resolving file paths, file_picker for the directory picker (used later), and dart:io for File and Directory classes.
  • TabController manages two tabs. SingleTickerProviderStateMixin provides the animation ticker the tab controller needs.
  • The PrivateNotesTab and SharedNotesTab widgets are referenced here but not yet defined. You will add them next.
  • Directly below the HomeScreen class, add the PrivateNotesTab widget with its state class and file-loading logic:
// --- Private Notes Tab (App-Scoped Storage) ---

class PrivateNotesTab extends StatefulWidget {
  const PrivateNotesTab({super.key});

  @override
  State<PrivateNotesTab> createState() => _PrivateNotesTabState();
}

class _PrivateNotesTabState extends State<PrivateNotesTab> {
  // Holds the list of .txt files found in the private notes directory
  List<FileSystemEntity> _files = [];
  // Caches the resolved app documents path for reuse in CRUD methods
  String? _appDocPath;

  @override
  void initState() {
    super.initState();
    _loadFiles();
  }

  // Resolves the app documents directory and lists all .txt files
  Future<void> _loadFiles() async {
    final dir = await getApplicationDocumentsDirectory();
    _appDocPath = dir.path;
    final notesDir = Directory('${dir.path}/private_notes');
    if (!notesDir.existsSync()) {
      notesDir.createSync();
    }
    setState(() {
      _files = notesDir
          .listSync()
          .where((f) => f.path.endsWith('.txt'))
          .toList();
    });
  }
}

What does this code do?

getApplicationDocumentsDirectory() resolves to the platform's app-private documents folder. The method then creates a private_notes subdirectory inside it if one does not already exist.

listSync() reads the directory contents synchronously, and the .where() filter ensures only .txt files appear in the list. setState() triggers a UI rebuild with the updated file list.

What is app-scoped storage?

App-scoped storage is a private directory that belongs exclusively to your app. No other app can access it, and no user permission is required.

The tradeoff: files in this directory are automatically deleted when the user uninstalls the app. This makes it perfect for data the user only needs inside your app.

  • Still inside the _PrivateNotesTabState class (below _loadFiles), add the create, edit, and delete methods:
  // Creates a new .txt file with the given filename and content
  Future<void> _createNote() async {
    final result = await _showNoteDialog();
    if (result == null) return;
    final file = File('$_appDocPath/private_notes/${result['filename']}.txt');
    await file.writeAsString(result['content']!);
    _loadFiles();
  }

  // Reads existing content, opens edit dialog, writes updated content
  Future<void> _editNote(File file) async {
    final content = await file.readAsString();
    final filename = file.path.split('/').last.replaceAll('.txt', '');
    final result = await _showNoteDialog(
      initialFilename: filename,
      initialContent: content,
    );
    if (result == null) return;
    await file.writeAsString(result['content']!);
    _loadFiles();
  }

  // Deletes the file and refreshes the list
  Future<void> _deleteNote(File file) async {
    await file.delete();
    _loadFiles();
  }

What does this code do?

  • _createNote() opens a dialog (which you will build next), then writes the returned content to a new .txt file using writeAsString().
  • _editNote() reads the file's current content with readAsString(), opens the dialog pre-filled, then overwrites the file with the updated text.
  • _deleteNote() calls delete() on the file object, then refreshes the list.
  • Every method ends by calling _loadFiles() to re-scan the directory and update the UI.
Build the Private Notes UI

The CRUD methods are in place. Now you need a dialog for entering note content and a list view for displaying notes.

  • Below _deleteNote in the same _PrivateNotesTabState class, add the dialog method:
  // Shows a dialog for creating or editing a note
  Future<Map<String, String>?> _showNoteDialog({
    String? initialFilename,
    String? initialContent,
  }) async {
    final filenameController = TextEditingController(text: initialFilename);
    final contentController = TextEditingController(text: initialContent);
    final isEditing = initialFilename != null;

    return showDialog<Map<String, String>>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(isEditing ? 'Edit Note' : 'New Note'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            // Only show filename field when creating a new note
            if (!isEditing)
              TextField(
                controller: filenameController,
                decoration: const InputDecoration(labelText: 'Filename'),
              ),
            const SizedBox(height: 8),
            TextField(
              controller: contentController,
              decoration: const InputDecoration(labelText: 'Content'),
              maxLines: 5,
            ),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () {
              final filename = isEditing
                  ? initialFilename
                  : filenameController.text.trim();
              if (filename.isEmpty) return;
              Navigator.pop(context, {
                'filename': filename,
                'content': contentController.text,
              });
            },
            child: const Text('Save'),
          ),
        ],
      ),
    );
  }

What does this code do?

_showNoteDialog() is a reusable dialog that works for both creating and editing notes.

  • When creating, it shows both a filename field and a content field.
  • When editing, it hides the filename field and pre-fills the content.
  • It returns a Map<String, String> with the filename and content, or null if the user cancels.
  • Now add the build() method that renders the note list, info banner, and create button. Add this below _showNoteDialog to complete the _PrivateNotesTabState class:
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Info banner explaining private storage behavior
        Container(
          width: double.infinity,
          padding: const EdgeInsets.all(12),
          color: Colors.indigo.shade50,
          child: const Text(
            'Private storage: no permissions needed. Files are deleted when you uninstall the app.',
            style: TextStyle(fontSize: 12),
          ),
        ),
        Expanded(
          child: _files.isEmpty
              ? const Center(child: Text('No private notes yet.'))
              : ListView.builder(
                  itemCount: _files.length,
                  itemBuilder: (context, index) {
                    final file = File(_files[index].path);
                    final name =
                        file.path.split('/').last.replaceAll('.txt', '');
                    // Dismissible enables swipe-to-delete
                    return Dismissible(
                      key: Key(file.path),
                      onDismissed: (_) => _deleteNote(file),
                      background: Container(color: Colors.red),
                      child: ListTile(
                        leading: const Icon(Icons.note),
                        title: Text(name),
                        onTap: () => _editNote(file),
                      ),
                    );
                  },
                ),
        ),
        // Floating action button for creating new notes
        Padding(
          padding: const EdgeInsets.all(16),
          child: FloatingActionButton.extended(
            heroTag: 'private_fab',
            onPressed: _createNote,
            icon: const Icon(Icons.add),
            label: const Text('New Private Note'),
          ),
        ),
      ],
    );
  }
}

What does this code do?

  • The info banner at the top reminds the user that private files require no permissions and are deleted on uninstall.
  • ListView.builder renders each .txt file as a ListTile. Tapping opens the edit dialog. Wrapping in Dismissible enables swipe-to-delete.
  • FloatingActionButton.extended gives users a clear button to create new notes.
  • Finally, add a placeholder SharedNotesTab class at the very bottom of the file. You will build this out in the next step:
// --- Shared Notes Tab (User-Chosen Directory) ---

class SharedNotesTab extends StatefulWidget {
  const SharedNotesTab({super.key});

  @override
  State<SharedNotesTab> createState() => _SharedNotesTabState();
}

class _SharedNotesTabState extends State<SharedNotesTab> {
  String? _selectedDirPath;
  List<FileSystemEntity> _files = [];
  String? _error;

  @override
  Widget build(BuildContext context) {
    return const Center(child: Text('Shared Notes - Coming Soon'));
  }
}

Why include state variables now?

The SharedNotesTab declares _selectedDirPath, _files, and _error upfront so the class compiles cleanly. You will implement the directory-picking logic in the next step.

  • Save lib/main.dart.

Seeing compile errors?

  • Make sure the closing brace } for _PrivateNotesTabState comes after the build() method. Each class needs its own closing brace.
  • Verify that PrivateNotesTab and SharedNotesTab are defined below HomeScreen. Dart reads files top-to-bottom, but classes can reference forward declarations.
  • Check that the import 'dart:io'; line is present at the top. Without it, File and Directory are undefined.

Help me fix compile errors in my Notes Vault Flutter app

✔️ Awesome, I've got everything!

Great. Make sure you have saved lib/main.dart before moving on.

ⓧ I'd like to double check the full code

Here is the complete lib/main.dart at this point:

import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:file_picker/file_picker.dart';
import 'dart:io';

void main() {
  runApp(const NotesVaultApp());
}

class NotesVaultApp extends StatelessWidget {
  const NotesVaultApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Notes Vault',
      theme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
      ),
      home: const HomeScreen(),
    );
  }
}

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen>
    with SingleTickerProviderStateMixin {
  late TabController _tabController;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 2, vsync: this);
  }

  @override
  void dispose() {
    _tabController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Notes Vault'),
        bottom: TabBar(
          controller: _tabController,
          tabs: const [
            Tab(icon: Icon(Icons.lock), text: 'Private'),
            Tab(icon: Icon(Icons.folder_open), text: 'Shared'),
          ],
        ),
      ),
      body: TabBarView(
        controller: _tabController,
        children: const [
          PrivateNotesTab(),
          SharedNotesTab(),
        ],
      ),
    );
  }
}

// --- Private Notes Tab (App-Scoped Storage) ---

class PrivateNotesTab extends StatefulWidget {
  const PrivateNotesTab({super.key});

  @override
  State<PrivateNotesTab> createState() => _PrivateNotesTabState();
}

class _PrivateNotesTabState extends State<PrivateNotesTab> {
  List<FileSystemEntity> _files = [];
  String? _appDocPath;

  @override
  void initState() {
    super.initState();
    _loadFiles();
  }

  Future<void> _loadFiles() async {
    final dir = await getApplicationDocumentsDirectory();
    _appDocPath = dir.path;
    final notesDir = Directory('${dir.path}/private_notes');
    if (!notesDir.existsSync()) {
      notesDir.createSync();
    }
    setState(() {
      _files = notesDir
          .listSync()
          .where((f) => f.path.endsWith('.txt'))
          .toList();
    });
  }

  Future<void> _createNote() async {
    final result = await _showNoteDialog();
    if (result == null) return;
    final file = File('$_appDocPath/private_notes/${result['filename']}.txt');
    await file.writeAsString(result['content']!);
    _loadFiles();
  }

  Future<void> _editNote(File file) async {
    final content = await file.readAsString();
    final filename = file.path.split('/').last.replaceAll('.txt', '');
    final result = await _showNoteDialog(
      initialFilename: filename,
      initialContent: content,
    );
    if (result == null) return;
    await file.writeAsString(result['content']!);
    _loadFiles();
  }

  Future<void> _deleteNote(File file) async {
    await file.delete();
    _loadFiles();
  }

  Future<Map<String, String>?> _showNoteDialog({
    String? initialFilename,
    String? initialContent,
  }) async {
    final filenameController = TextEditingController(text: initialFilename);
    final contentController = TextEditingController(text: initialContent);
    final isEditing = initialFilename != null;

    return showDialog<Map<String, String>>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(isEditing ? 'Edit Note' : 'New Note'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            if (!isEditing)
              TextField(
                controller: filenameController,
                decoration: const InputDecoration(labelText: 'Filename'),
              ),
            const SizedBox(height: 8),
            TextField(
              controller: contentController,
              decoration: const InputDecoration(labelText: 'Content'),
              maxLines: 5,
            ),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () {
              final filename = isEditing
                  ? initialFilename
                  : filenameController.text.trim();
              if (filename.isEmpty) return;
              Navigator.pop(context, {
                'filename': filename,
                'content': contentController.text,
              });
            },
            child: const Text('Save'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
          width: double.infinity,
          padding: const EdgeInsets.all(12),
          color: Colors.indigo.shade50,
          child: const Text(
            'Private storage: no permissions needed. Files are deleted when you uninstall the app.',
            style: TextStyle(fontSize: 12),
          ),
        ),
        Expanded(
          child: _files.isEmpty
              ? const Center(child: Text('No private notes yet.'))
              : ListView.builder(
                  itemCount: _files.length,
                  itemBuilder: (context, index) {
                    final file = File(_files[index].path);
                    final name =
                        file.path.split('/').last.replaceAll('.txt', '');
                    return Dismissible(
                      key: Key(file.path),
                      onDismissed: (_) => _deleteNote(file),
                      background: Container(color: Colors.red),
                      child: ListTile(
                        leading: const Icon(Icons.note),
                        title: Text(name),
                        onTap: () => _editNote(file),
                      ),
                    );
                  },
                ),
        ),
        Padding(
          padding: const EdgeInsets.all(16),
          child: FloatingActionButton.extended(
            heroTag: 'private_fab',
            onPressed: _createNote,
            icon: const Icon(Icons.add),
            label: const Text('New Private Note'),
          ),
        ),
      ],
    );
  }
}

// --- Shared Notes Tab (User-Chosen Directory) ---

class SharedNotesTab extends StatefulWidget {
  const SharedNotesTab({super.key});

  @override
  State<SharedNotesTab> createState() => _SharedNotesTabState();
}

class _SharedNotesTabState extends State<SharedNotesTab> {
  String? _selectedDirPath;
  List<FileSystemEntity> _files = [];
  String? _error;

  @override
  Widget build(BuildContext context) {
    return const Center(child: Text('Shared Notes - Coming Soon'));
  }
}
Test the CRUD operations

Your file service and UI are wired together. Time to verify everything works on both platforms.

  • Run the app on your Android emulator by running:
flutter run
  • Tap the New Private Note button at the bottom.
  • Enter hello in the Filename field and This is my first note in the Content field.
  • Tap Save.
  • Verify the note appears in the list as hello.
  • Tap the note to open the edit dialog. Change the content and tap Save.
  • Swipe the note to the left or right to delete it. Verify it disappears from the list.
  • Create 2-3 more notes to confirm repeated operations work.

You should see notes appear in the list as you create them, update when you edit, and vanish when you swipe to delete.

  • Stop the app, then run it again on your iOS simulator. Repeat the same create, edit, and delete tests to confirm cross-platform behavior.

Why test on both platforms?

Although path_provider abstracts the underlying directory, the actual file system paths differ. Android uses /data/data/<package>/files while iOS uses the app's sandboxed Documents/ folder. Testing both ensures your file operations work regardless of the underlying path structure.

Notes not showing up after creation?

  • Make sure you entered text in both the Filename and Content fields. An empty filename silently skips the save.
  • Hot restart the app (press R in the terminal) to force a fresh _loadFiles() call.
  • Check the terminal for error messages. A FileSystemException usually means the directory path is wrong.

Help me debug why my private notes are not appearing in the list

Your Private Notes are working. Next up, you will add the Shared tab that lets users pick any directory on their device and store files outside the app's sandbox.

Add the Directory Picker for Shared Notes

Your Private Notes tab handles files that live inside the app's sandbox. Now you need a second storage mode that lets the user choose any directory on their device.

On Android, the Storage Access Framework (SAF) lets the system picker grant directory access with zero manifest permissions. On iOS, UIDocumentPickerViewController extends the sandbox to a user-chosen folder. In this step, you will wire up the directory picker and display its contents.

In this step, get ready to:
  • Implement directory picking with FilePicker.getDirectoryPath().
  • List .txt files from the user-chosen directory.
  • Build a placeholder UI for when no directory is selected.
Implement directory picking

The file_picker package provides a single method that invokes the native directory picker on both platforms. On Android it triggers ACTION_OPEN_DOCUMENT_TREE. On iOS it presents UIDocumentPickerViewController.

  • In lib/main.dart, scroll down to the _SharedNotesTabState class.
  • Find the line that says String? _error;.
  • Add the _pickDirectory() method below it by copying the following code:
  Future<void> _pickDirectory() async {
    // Open the native directory picker (SAF on Android, UIDocumentPicker on iOS)
    final path = await FilePicker.getDirectoryPath();
    // User cancelled the picker
    if (path == null) return;
    setState(() {
      _selectedDirPath = path;
      _error = null;
    });
    _loadFiles();
  }

What does this code do?

FilePicker.getDirectoryPath() opens the native file picker. It returns the selected directory's absolute path as a String?. If the user cancels, it returns null.

No manifest permissions are needed. The system picker itself acts as the user's consent. Once a path is returned, your app can read and write to that directory using standard dart:io operations.

Why no permissions at all?

On Android, the Storage Access Framework grants URI-based access through the system picker UI. You never declare READ_EXTERNAL_STORAGE or WRITE_EXTERNAL_STORAGE in your manifest.

On iOS, UIDocumentPickerViewController extends the app's sandbox to the selected directory. The user controls access via Settings > Privacy > Files and Folders. No extra entitlements are needed.

List files in the chosen directory

Once the user picks a directory, you need to enumerate its contents. You will filter for .txt files so the notes list stays focused.

  • Add the _loadFiles() method directly below _pickDirectory() by copying this code:
  void _loadFiles() {
    if (_selectedDirPath == null) return;
    try {
      final dir = Directory(_selectedDirPath!);
      setState(() {
        // List only .txt files in the chosen directory
        _files = dir
            .listSync()
            .where((f) => f.path.endsWith('.txt'))
            .toList();
        _error = null;
      });
    } catch (e) {
      // Handle permission revocation or missing directory
      setState(() {
        _error = 'Cannot access directory. Permission may have been revoked.';
        _files = [];
      });
    }
  }

What does this code do?

  • Directory(_selectedDirPath!).listSync() returns all entries in the directory synchronously.
  • The .where((f) => f.path.endsWith('.txt')) filter keeps only text files visible in the list.
  • The try-catch handles the case where access is revoked. On iOS the user can revoke access in Settings at any time. On Android the URI permission may not persist across device restarts.
Handle the "no directory selected" state

The Shared tab needs to handle three visual states: no directory selected yet, a directory is selected but empty, and a directory with files. You also need to show the chosen directory path and a button to change it.

  • Find the existing build method in _SharedNotesTabState (the one that returns the placeholder text).
  • Replace the entire build method with the following code:
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Info banner explaining shared storage behavior
        Container(
          width: double.infinity,
          padding: const EdgeInsets.all(12),
          color: Colors.orange.shade50,
          child: const Text(
            'Shared storage: user grants access via picker. Files persist independently of this app.',
            style: TextStyle(fontSize: 12),
          ),
        ),
        // Show error message if directory access fails
        if (_error != null)
          Container(
            width: double.infinity,
            padding: const EdgeInsets.all(12),
            color: Colors.red.shade50,
            child: Text(_error!, style: const TextStyle(color: Colors.red)),
          ),
        // Directory path display and Choose button
        Padding(
          padding: const EdgeInsets.all(12),
          child: Row(
            children: [
              Expanded(
                child: Text(
                  _selectedDirPath ?? 'No directory selected',
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(fontSize: 12),
                ),
              ),
              const SizedBox(width: 8),
              OutlinedButton.icon(
                onPressed: _pickDirectory,
                icon: const Icon(Icons.folder_open),
                label: const Text('Choose'),
              ),
            ],
          ),
        ),
        // File list area with three possible states
        Expanded(
          child: _selectedDirPath == null
              ? const Center(
                  child: Text('Pick a directory to get started.'),
                )
              : _files.isEmpty
                  ? const Center(child: Text('No .txt files in this directory.'))
                  : ListView.builder(
                      itemCount: _files.length,
                      itemBuilder: (context, index) {
                        final file = File(_files[index].path);
                        final name = file.path.split('/').last;
                        return ListTile(
                          leading: const Icon(Icons.description),
                          title: Text(name),
                          subtitle: Text(
                            '${file.lengthSync()} bytes',
                            style: const TextStyle(fontSize: 11),
                          ),
                        );
                      },
                    ),
        ),
      ],
    );
  }

What does this UI do?

  • The orange banner at the top reminds the user that shared files persist independently of the app.
  • The red error container only appears when _error is set (e.g. permission revoked).
  • The Row shows the current directory path (or a placeholder) next to a Choose button that triggers the picker.
  • The Expanded area handles three states: no directory yet, empty directory, or a list of .txt files showing name and size.
  • Save lib/main.dart.
  • Hot restart the app by pressing Shift+R in the terminal where flutter run is active.
  • Tap the Shared tab. You should see the orange info banner, a "No directory selected" label, and a Choose button.
  • Tap Choose and select any directory. The native picker will open (the appearance differs between Android and iOS).
  • After selecting a directory, you should see the path displayed and any existing .txt files listed with their sizes.

Directory picker not opening?

  • Make sure you saved the file and hot restarted (not just hot reloaded). New methods require a full restart.
  • On the iOS Simulator, the document picker may show limited directories. This is normal for the simulator. Test on a real device to see the full file system.
  • If the Android build fails with a missing symbol error, check that your project uses Gradle 8.x. There is a known issue with file_picker 11.0.2 and Gradle 9.x.

Help me debug why the directory picker is not opening in my Flutter app.

✔️ Awesome, I've got everything!

Great. Double check you have saved lib/main.dart.

ⓧ I'd like to double check the full code

Here is the full lib/main.dart at this point in the project.

import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:file_picker/file_picker.dart';
import 'dart:io';

void main() {
  runApp(const NotesVaultApp());
}

class NotesVaultApp extends StatelessWidget {
  const NotesVaultApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Notes Vault',
      theme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
      ),
      home: const HomeScreen(),
    );
  }
}

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen>
    with SingleTickerProviderStateMixin {
  late TabController _tabController;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 2, vsync: this);
  }

  @override
  void dispose() {
    _tabController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Notes Vault'),
        bottom: TabBar(
          controller: _tabController,
          tabs: const [
            Tab(icon: Icon(Icons.lock), text: 'Private'),
            Tab(icon: Icon(Icons.folder_open), text: 'Shared'),
          ],
        ),
      ),
      body: TabBarView(
        controller: _tabController,
        children: const [
          PrivateNotesTab(),
          SharedNotesTab(),
        ],
      ),
    );
  }
}

class PrivateNotesTab extends StatefulWidget {
  const PrivateNotesTab({super.key});

  @override
  State<PrivateNotesTab> createState() => _PrivateNotesTabState();
}

class _PrivateNotesTabState extends State<PrivateNotesTab> {
  List<FileSystemEntity> _files = [];
  String? _appDocPath;

  @override
  void initState() {
    super.initState();
    _loadFiles();
  }

  Future<void> _loadFiles() async {
    final dir = await getApplicationDocumentsDirectory();
    _appDocPath = dir.path;
    final notesDir = Directory('${dir.path}/private_notes');
    if (!notesDir.existsSync()) {
      notesDir.createSync();
    }
    setState(() {
      _files = notesDir
          .listSync()
          .where((f) => f.path.endsWith('.txt'))
          .toList();
    });
  }

  Future<void> _createNote() async {
    final result = await _showNoteDialog();
    if (result == null) return;
    final file = File('$_appDocPath/private_notes/${result['filename']}.txt');
    await file.writeAsString(result['content']!);
    _loadFiles();
  }

  Future<void> _editNote(File file) async {
    final content = await file.readAsString();
    final filename = file.path.split('/').last.replaceAll('.txt', '');
    final result = await _showNoteDialog(
      initialFilename: filename,
      initialContent: content,
    );
    if (result == null) return;
    await file.writeAsString(result['content']!);
    _loadFiles();
  }

  Future<void> _deleteNote(File file) async {
    await file.delete();
    _loadFiles();
  }

  Future<Map<String, String>?> _showNoteDialog({
    String? initialFilename,
    String? initialContent,
  }) async {
    final filenameController = TextEditingController(text: initialFilename);
    final contentController = TextEditingController(text: initialContent);
    final isEditing = initialFilename != null;

    return showDialog<Map<String, String>>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(isEditing ? 'Edit Note' : 'New Note'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            if (!isEditing)
              TextField(
                controller: filenameController,
                decoration: const InputDecoration(labelText: 'Filename'),
              ),
            const SizedBox(height: 8),
            TextField(
              controller: contentController,
              decoration: const InputDecoration(labelText: 'Content'),
              maxLines: 5,
            ),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () {
              final filename = isEditing
                  ? initialFilename
                  : filenameController.text.trim();
              if (filename.isEmpty) return;
              Navigator.pop(context, {
                'filename': filename,
                'content': contentController.text,
              });
            },
            child: const Text('Save'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
          width: double.infinity,
          padding: const EdgeInsets.all(12),
          color: Colors.indigo.shade50,
          child: const Text(
            'Private storage: no permissions needed. Files are deleted when you uninstall the app.',
            style: TextStyle(fontSize: 12),
          ),
        ),
        Expanded(
          child: _files.isEmpty
              ? const Center(child: Text('No private notes yet.'))
              : ListView.builder(
                  itemCount: _files.length,
                  itemBuilder: (context, index) {
                    final file = File(_files[index].path);
                    final name =
                        file.path.split('/').last.replaceAll('.txt', '');
                    return Dismissible(
                      key: Key(file.path),
                      onDismissed: (_) => _deleteNote(file),
                      background: Container(color: Colors.red),
                      child: ListTile(
                        leading: const Icon(Icons.note),
                        title: Text(name),
                        onTap: () => _editNote(file),
                      ),
                    );
                  },
                ),
        ),
        Padding(
          padding: const EdgeInsets.all(16),
          child: FloatingActionButton.extended(
            heroTag: 'private_fab',
            onPressed: _createNote,
            icon: const Icon(Icons.add),
            label: const Text('New Private Note'),
          ),
        ),
      ],
    );
  }
}

class SharedNotesTab extends StatefulWidget {
  const SharedNotesTab({super.key});

  @override
  State<SharedNotesTab> createState() => _SharedNotesTabState();
}

class _SharedNotesTabState extends State<SharedNotesTab> {
  String? _selectedDirPath;
  List<FileSystemEntity> _files = [];
  String? _error;

  Future<void> _pickDirectory() async {
    final path = await FilePicker.getDirectoryPath();
    if (path == null) return;
    setState(() {
      _selectedDirPath = path;
      _error = null;
    });
    _loadFiles();
  }

  void _loadFiles() {
    if (_selectedDirPath == null) return;
    try {
      final dir = Directory(_selectedDirPath!);
      setState(() {
        _files = dir
            .listSync()
            .where((f) => f.path.endsWith('.txt'))
            .toList();
        _error = null;
      });
    } catch (e) {
      setState(() {
        _error = 'Cannot access directory. Permission may have been revoked.';
        _files = [];
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
          width: double.infinity,
          padding: const EdgeInsets.all(12),
          color: Colors.orange.shade50,
          child: const Text(
            'Shared storage: user grants access via picker. Files persist independently of this app.',
            style: TextStyle(fontSize: 12),
          ),
        ),
        if (_error != null)
          Container(
            width: double.infinity,
            padding: const EdgeInsets.all(12),
            color: Colors.red.shade50,
            child: Text(_error!, style: const TextStyle(color: Colors.red)),
          ),
        Padding(
          padding: const EdgeInsets.all(12),
          child: Row(
            children: [
              Expanded(
                child: Text(
                  _selectedDirPath ?? 'No directory selected',
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(fontSize: 12),
                ),
              ),
              const SizedBox(width: 8),
              OutlinedButton.icon(
                onPressed: _pickDirectory,
                icon: const Icon(Icons.folder_open),
                label: const Text('Choose'),
              ),
            ],
          ),
        ),
        Expanded(
          child: _selectedDirPath == null
              ? const Center(
                  child: Text('Pick a directory to get started.'),
                )
              : _files.isEmpty
                  ? const Center(child: Text('No .txt files in this directory.'))
                  : ListView.builder(
                      itemCount: _files.length,
                      itemBuilder: (context, index) {
                        final file = File(_files[index].path);
                        final name = file.path.split('/').last;
                        return ListTile(
                          leading: const Icon(Icons.description),
                          title: Text(name),
                          subtitle: Text(
                            '${file.lengthSync()} bytes',
                            style: const TextStyle(fontSize: 11),
                          ),
                        );
                      },
                    ),
        ),
      ],
    );
  }
}

You can now browse any directory the user picks. Next up, you will add full CRUD operations so the user can create, edit, and delete notes in that shared directory.

Complete Shared Notes CRUD and Tabbed UI

Your Private Notes tab already handles full CRUD operations. Now you need to add the same create, edit, and delete capabilities to the Shared tab.

The key difference here is error handling. Since the user-chosen directory lives outside your app's sandbox, any file operation might fail if the user revokes access or the directory is deleted. Every write and read needs a try-catch to handle these failures gracefully.

In this step, get ready to:
  • Add create, edit, and delete methods with platform-aware error handling.
  • Build a note dialog for the Shared tab.
  • Wire up interactive controls and verify full CRUD on both platforms.
Implement create and write operations

Files written to a user-chosen directory are NOT owned by your app. They persist independently. If the user uninstalls your app, these files remain on disk.

  • In lib/main.dart, find the _SharedNotesTabState class.
  • Add the following _createNote() method below the existing _loadFiles() method:
  Future<void> _createNote() async {
    if (_selectedDirPath == null) return;
    final result = await _showNoteDialog();
    if (result == null) return;
    try {
      // Write a new .txt file to the user-chosen directory
      final file = File('$_selectedDirPath/${result['filename']}.txt');
      await file.writeAsString(result['content']!);
      _loadFiles();
    } catch (e) {
      // Handle permission revocation or disk errors
      setState(() {
        _error = 'Failed to create file: $e';
      });
    }
  }

What does this code do?

The _createNote() method builds a file path by combining the user-chosen directory with the filename from the dialog.

The entire write operation is wrapped in a try-catch block. On iOS, if the user revokes access via Settings, the write will throw an exception. On Android, if the SAF URI permission has expired, the same thing happens. The catch block surfaces the error in the UI instead of crashing the app.

Why try-catch on every file operation?

In the Private tab, file operations always succeed because the app owns its sandbox directory. The Shared tab is different. The user can revoke folder access at any time through their device settings.

On iOS, the user can go to Settings > Privacy > Files and Folders and toggle your app off. On Android, persistent URI grants can expire if the document is moved or deleted. Wrapping operations in try-catch ensures your app handles these scenarios gracefully.

Implement read, edit, and delete
  • Add the _editNote() method below _createNote():
  Future<void> _editNote(File file) async {
    try {
      // Read the file's current content
      final content = await file.readAsString();
      final filename = file.path.split('/').last.replaceAll('.txt', '');
      final result = await _showNoteDialog(
        initialFilename: filename,
        initialContent: content,
      );
      if (result == null) return;
      // Overwrite with updated content
      await file.writeAsString(result['content']!);
      _loadFiles();
    } catch (e) {
      setState(() {
        _error = 'Failed to access file: $e';
      });
    }
  }

What does this code do?

The _editNote() method reads the current file content with file.readAsString(), opens the dialog pre-filled with that content, then overwrites the file with file.writeAsString().

The whole operation is wrapped in try-catch because even reading can fail if access was revoked between listing and tapping.

  • Add the _deleteNote() method below _editNote():
  Future<void> _deleteNote(File file) async {
    try {
      await file.delete();
      _loadFiles();
    } catch (e) {
      setState(() {
        _error = 'Failed to delete file: $e';
      });
    }
  }

What does this code do?

The _deleteNote() method calls file.delete() and refreshes the list. The try-catch catches the case where the file was already removed or access was revoked.

  • Add the _showNoteDialog() method below _deleteNote():
  Future<Map<String, String>?> _showNoteDialog({
    String? initialFilename,
    String? initialContent,
  }) async {
    final filenameController = TextEditingController(text: initialFilename);
    final contentController = TextEditingController(text: initialContent);
    final isEditing = initialFilename != null;

    return showDialog<Map<String, String>>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(isEditing ? 'Edit Note' : 'New Shared Note'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            // Only show filename field when creating a new note
            if (!isEditing)
              TextField(
                controller: filenameController,
                decoration: const InputDecoration(labelText: 'Filename'),
              ),
            const SizedBox(height: 8),
            TextField(
              controller: contentController,
              decoration: const InputDecoration(labelText: 'Content'),
              maxLines: 5,
            ),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () {
              final filename = isEditing
                  ? initialFilename
                  : filenameController.text.trim();
              if (filename.isEmpty) return;
              Navigator.pop(context, {
                'filename': filename,
                'content': contentController.text,
              });
            },
            child: const Text('Save'),
          ),
        ],
      ),
    );
  }

What does this code do?

This dialog serves double duty. When creating a new note, it shows both a filename field and a content field. When editing, it hides the filename field and pre-fills the content.

Notice the dialog title changes to 'New Shared Note' for creation. This helps the user distinguish between the Private and Shared dialogs.

  • Save lib/main.dart.

Seeing a red underline on _showNoteDialog?

The _createNote() and _editNote() methods reference _showNoteDialog(). If you added the methods in a different order, the analyzer may show a warning until the dialog method exists. Make sure all four methods are present before worrying about errors.

help me fix analyzer errors in my SharedNotesTab

Wire up the interactive list and create button

The methods are ready, but the UI does not use them yet. You need to wrap each list tile in a Dismissible widget for swipe-to-delete, add an onTap handler for editing, and show a floating action button for creating new notes.

  • In the build() method of _SharedNotesTabState, find the ListView.builder section.
  • Find this line inside the itemBuilder:
                        return ListTile(
                          leading: const Icon(Icons.description),
                          title: Text(name),
                          subtitle: Text(
                            '${file.lengthSync()} bytes',
                            style: const TextStyle(fontSize: 11),
                          ),
                        );
  • Replace it with a Dismissible wrapper that connects swipe-to-delete and tap-to-edit:
                        return Dismissible(
                          key: Key(file.path),
                          onDismissed: (_) => _deleteNote(file),
                          background: Container(color: Colors.red),
                          child: ListTile(
                            leading: const Icon(Icons.description),
                            title: Text(name),
                            subtitle: Text(
                              '${file.lengthSync()} bytes',
                              style: const TextStyle(fontSize: 11),
                            ),
                            onTap: () => _editNote(file),
                          ),
                        );

What does this change?

The Dismissible widget enables swipe-to-delete. When the user swipes a list tile, it calls _deleteNote(file) and the red background reveals underneath.

The onTap handler opens the edit dialog when the user taps a note. This mirrors the Private tab's behavior.

  • Next, find the closing bracket of the Expanded widget (just before the final ], of the Column's children list).
  • Add the floating action button below the Expanded widget by pasting this code before the closing ], of the Column:
        // Show the create button only when a directory is selected
        if (_selectedDirPath != null)
          Padding(
            padding: const EdgeInsets.all(16),
            child: FloatingActionButton.extended(
              heroTag: 'shared_fab',
              onPressed: _createNote,
              icon: const Icon(Icons.add),
              label: const Text('New Shared Note'),
            ),
          ),

Why the heroTag?

Flutter requires a unique heroTag when multiple FloatingActionButtons exist in the same widget tree. The Private tab uses 'private_fab' and this one uses 'shared_fab' to avoid a hero animation conflict.

  • Save lib/main.dart.
  • Hot restart the app on your Android emulator or iOS simulator.
  • Switch to the **Shared** tab.
  • Tap **Choose** and select a directory.
  • Tap the **New Shared Note** button, enter a filename and content, then tap **Save**.

You should see the new note appear in the list with its filename and byte count.

  • Tap the note to edit it. Change the content and save.
  • Swipe the note to the left or right to delete it.
  • Open the device's file manager app (Files on iOS, Files or a file manager on Android) and navigate to the directory you chose. You should see your .txt files listed there.

What about the file lifetime difference?

To prove the lifecycle difference, try uninstalling and reinstalling the app. Your Private notes will be gone because those files lived inside the app's sandbox. Your Shared notes will still exist in the directory you chose because they belong to the filesystem, not to your app.

New Shared Note button not appearing?

The button only shows when _selectedDirPath is not null. Make sure you've tapped **Choose** and selected a directory first.

If you see an error banner saying the file could not be created, check that you selected a writable directory. On iOS Simulator, try the Downloads folder. On Android Emulator, try the Documents folder.

help me debug my Shared Notes tab

✔️ Awesome, I've got everything!

Great. Double check you've saved lib/main.dart and that the app runs without errors on both platforms.

ⓧ I'd like to double check the full code

Here is the complete lib/main.dart as it should look at the end of this step:

import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:file_picker/file_picker.dart';
import 'dart:io';

void main() {
  runApp(const NotesVaultApp());
}

class NotesVaultApp extends StatelessWidget {
  const NotesVaultApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Notes Vault',
      theme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
      ),
      home: const HomeScreen(),
    );
  }
}

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen>
    with SingleTickerProviderStateMixin {
  late TabController _tabController;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 2, vsync: this);
  }

  @override
  void dispose() {
    _tabController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Notes Vault'),
        bottom: TabBar(
          controller: _tabController,
          tabs: const [
            Tab(icon: Icon(Icons.lock), text: 'Private'),
            Tab(icon: Icon(Icons.folder_open), text: 'Shared'),
          ],
        ),
      ),
      body: TabBarView(
        controller: _tabController,
        children: const [
          PrivateNotesTab(),
          SharedNotesTab(),
        ],
      ),
    );
  }
}

class PrivateNotesTab extends StatefulWidget {
  const PrivateNotesTab({super.key});

  @override
  State<PrivateNotesTab> createState() => _PrivateNotesTabState();
}

class _PrivateNotesTabState extends State<PrivateNotesTab> {
  List<FileSystemEntity> _files = [];
  String? _appDocPath;

  @override
  void initState() {
    super.initState();
    _loadFiles();
  }

  Future<void> _loadFiles() async {
    final dir = await getApplicationDocumentsDirectory();
    _appDocPath = dir.path;
    final notesDir = Directory('${dir.path}/private_notes');
    if (!notesDir.existsSync()) {
      notesDir.createSync();
    }
    setState(() {
      _files = notesDir
          .listSync()
          .where((f) => f.path.endsWith('.txt'))
          .toList();
    });
  }

  Future<void> _createNote() async {
    final result = await _showNoteDialog();
    if (result == null) return;
    final file = File('$_appDocPath/private_notes/${result['filename']}.txt');
    await file.writeAsString(result['content']!);
    _loadFiles();
  }

  Future<void> _editNote(File file) async {
    final content = await file.readAsString();
    final filename = file.path.split('/').last.replaceAll('.txt', '');
    final result = await _showNoteDialog(
      initialFilename: filename,
      initialContent: content,
    );
    if (result == null) return;
    await file.writeAsString(result['content']!);
    _loadFiles();
  }

  Future<void> _deleteNote(File file) async {
    await file.delete();
    _loadFiles();
  }

  Future<Map<String, String>?> _showNoteDialog({
    String? initialFilename,
    String? initialContent,
  }) async {
    final filenameController = TextEditingController(text: initialFilename);
    final contentController = TextEditingController(text: initialContent);
    final isEditing = initialFilename != null;

    return showDialog<Map<String, String>>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(isEditing ? 'Edit Note' : 'New Note'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            if (!isEditing)
              TextField(
                controller: filenameController,
                decoration: const InputDecoration(labelText: 'Filename'),
              ),
            const SizedBox(height: 8),
            TextField(
              controller: contentController,
              decoration: const InputDecoration(labelText: 'Content'),
              maxLines: 5,
            ),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () {
              final filename = isEditing
                  ? initialFilename
                  : filenameController.text.trim();
              if (filename.isEmpty) return;
              Navigator.pop(context, {
                'filename': filename,
                'content': contentController.text,
              });
            },
            child: const Text('Save'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
          width: double.infinity,
          padding: const EdgeInsets.all(12),
          color: Colors.indigo.shade50,
          child: const Text(
            'Private storage: no permissions needed. Files are deleted when you uninstall the app.',
            style: TextStyle(fontSize: 12),
          ),
        ),
        Expanded(
          child: _files.isEmpty
              ? const Center(child: Text('No private notes yet.'))
              : ListView.builder(
                  itemCount: _files.length,
                  itemBuilder: (context, index) {
                    final file = File(_files[index].path);
                    final name =
                        file.path.split('/').last.replaceAll('.txt', '');
                    return Dismissible(
                      key: Key(file.path),
                      onDismissed: (_) => _deleteNote(file),
                      background: Container(color: Colors.red),
                      child: ListTile(
                        leading: const Icon(Icons.note),
                        title: Text(name),
                        onTap: () => _editNote(file),
                      ),
                    );
                  },
                ),
        ),
        Padding(
          padding: const EdgeInsets.all(16),
          child: FloatingActionButton.extended(
            heroTag: 'private_fab',
            onPressed: _createNote,
            icon: const Icon(Icons.add),
            label: const Text('New Private Note'),
          ),
        ),
      ],
    );
  }
}

class SharedNotesTab extends StatefulWidget {
  const SharedNotesTab({super.key});

  @override
  State<SharedNotesTab> createState() => _SharedNotesTabState();
}

class _SharedNotesTabState extends State<SharedNotesTab> {
  String? _selectedDirPath;
  List<FileSystemEntity> _files = [];
  String? _error;

  Future<void> _pickDirectory() async {
    final path = await FilePicker.getDirectoryPath();
    if (path == null) return;
    setState(() {
      _selectedDirPath = path;
      _error = null;
    });
    _loadFiles();
  }

  void _loadFiles() {
    if (_selectedDirPath == null) return;
    try {
      final dir = Directory(_selectedDirPath!);
      setState(() {
        _files = dir
            .listSync()
            .where((f) => f.path.endsWith('.txt'))
            .toList();
        _error = null;
      });
    } catch (e) {
      setState(() {
        _error = 'Cannot access directory. Permission may have been revoked.';
        _files = [];
      });
    }
  }

  Future<void> _createNote() async {
    if (_selectedDirPath == null) return;
    final result = await _showNoteDialog();
    if (result == null) return;
    try {
      final file = File('$_selectedDirPath/${result['filename']}.txt');
      await file.writeAsString(result['content']!);
      _loadFiles();
    } catch (e) {
      setState(() {
        _error = 'Failed to create file: $e';
      });
    }
  }

  Future<void> _editNote(File file) async {
    try {
      final content = await file.readAsString();
      final filename = file.path.split('/').last.replaceAll('.txt', '');
      final result = await _showNoteDialog(
        initialFilename: filename,
        initialContent: content,
      );
      if (result == null) return;
      await file.writeAsString(result['content']!);
      _loadFiles();
    } catch (e) {
      setState(() {
        _error = 'Failed to access file: $e';
      });
    }
  }

  Future<void> _deleteNote(File file) async {
    try {
      await file.delete();
      _loadFiles();
    } catch (e) {
      setState(() {
        _error = 'Failed to delete file: $e';
      });
    }
  }

  Future<Map<String, String>?> _showNoteDialog({
    String? initialFilename,
    String? initialContent,
  }) async {
    final filenameController = TextEditingController(text: initialFilename);
    final contentController = TextEditingController(text: initialContent);
    final isEditing = initialFilename != null;

    return showDialog<Map<String, String>>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(isEditing ? 'Edit Note' : 'New Shared Note'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            if (!isEditing)
              TextField(
                controller: filenameController,
                decoration: const InputDecoration(labelText: 'Filename'),
              ),
            const SizedBox(height: 8),
            TextField(
              controller: contentController,
              decoration: const InputDecoration(labelText: 'Content'),
              maxLines: 5,
            ),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () {
              final filename = isEditing
                  ? initialFilename
                  : filenameController.text.trim();
              if (filename.isEmpty) return;
              Navigator.pop(context, {
                'filename': filename,
                'content': contentController.text,
              });
            },
            child: const Text('Save'),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
          width: double.infinity,
          padding: const EdgeInsets.all(12),
          color: Colors.orange.shade50,
          child: const Text(
            'Shared storage: user grants access via picker. Files persist independently of this app.',
            style: TextStyle(fontSize: 12),
          ),
        ),
        if (_error != null)
          Container(
            width: double.infinity,
            padding: const EdgeInsets.all(12),
            color: Colors.red.shade50,
            child: Text(_error!, style: const TextStyle(color: Colors.red)),
          ),
        Padding(
          padding: const EdgeInsets.all(12),
          child: Row(
            children: [
              Expanded(
                child: Text(
                  _selectedDirPath ?? 'No directory selected',
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(fontSize: 12),
                ),
              ),
              const SizedBox(width: 8),
              OutlinedButton.icon(
                onPressed: _pickDirectory,
                icon: const Icon(Icons.folder_open),
                label: const Text('Choose'),
              ),
            ],
          ),
        ),
        Expanded(
          child: _selectedDirPath == null
              ? const Center(
                  child: Text('Pick a directory to get started.'),
                )
              : _files.isEmpty
                  ? const Center(child: Text('No .txt files in this directory.'))
                  : ListView.builder(
                      itemCount: _files.length,
                      itemBuilder: (context, index) {
                        final file = File(_files[index].path);
                        final name = file.path.split('/').last;
                        return Dismissible(
                          key: Key(file.path),
                          onDismissed: (_) => _deleteNote(file),
                          background: Container(color: Colors.red),
                          child: ListTile(
                            leading: const Icon(Icons.description),
                            title: Text(name),
                            subtitle: Text(
                              '${file.lengthSync()} bytes',
                              style: const TextStyle(fontSize: 11),
                            ),
                            onTap: () => _editNote(file),
                          ),
                        );
                      },
                    ),
        ),
        if (_selectedDirPath != null)
          Padding(
            padding: const EdgeInsets.all(16),
            child: FloatingActionButton.extended(
              heroTag: 'shared_fab',
              onPressed: _createNote,
              icon: const Icon(Icons.add),
              label: const Text('New Shared Note'),
            ),
          ),
      ],
    );
  }
}

Both tabs now support full CRUD operations with proper error handling. Next up, you'll extend the app with a Secret Mission that persists the user's directory choice across app restarts.

Secret mission

Persist Directory Access Across App Restarts

Right now, every time you close and reopen the app, the Shared tab forgets which directory you chose. Can you make the app remember the user's directory choice so they never have to re-pick it? This involves saving the path, restoring it on launch, and gracefully handling the case where access has been revoked.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with no cloud services, so there are no ongoing costs.

Resources you used:

  • The notes_vault project folder on your Desktop.
  • The Notes Vault app installed on your Android emulator and iOS simulator.
  • Text files you created in your user-chosen shared directory.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the app.

  • Your notes_vault project folder stays on your Desktop, ready for further development.
  • Private notes remain in the app's documents directory until you uninstall.
  • Shared notes persist in your chosen directory regardless of what you do with the app.

Pause - I'll come back to this later

Shut down running simulators and emulators to free up memory. Your project files stay on disk for when you return.

  • In your terminal, press Ctrl+C to stop the running Flutter app if it is still active.
  • Close your Android emulator from the emulator window.
  • Close your iOS simulator by quitting the Simulator app.
  • Your notes_vault folder and all source code remain intact on your Desktop.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes the source code, uninstalls the app, and cleans up any notes you created.

  • Delete the project folder by running this command in your terminal:
rm -rf ~/Desktop/notes_vault
  • Uninstall the Notes Vault app from your iOS simulator: long-press the app icon, tap Remove App, then tap Delete App. This automatically deletes all private notes stored in the app's documents directory.
  • Uninstall the Notes Vault app from your Android emulator: long-press the app icon, drag it to Uninstall, and confirm. This automatically deletes all private notes stored in the app's internal files directory.
  • Manually delete any shared notes from your user-chosen directory. Open Finder, navigate to the directory you selected during the project, and delete the .txt files you created.

Nice Work!

Nice Work!

You just built a cross-platform Notes Vault app that handles file storage across both Android and iOS. You now have hands-on experience with two fundamentally different storage models and the native permission systems that govern them.

You've learned how to:

  • Build full CRUD file operations in Flutter using dart:io, with path_provider for app-scoped storage that requires zero permissions but is deleted on uninstall.
  • Use the native file picker to invoke Android's Storage Access Framework and iOS's UIDocumentPickerViewController for user-chosen directory access with no manifest permissions or extra entitlements.
  • Implement platform-aware error handling for permission revocation scenarios, and understand the file lifecycle difference between app-scoped files (deleted on uninstall) and user-chosen directory files (persist independently).
  • Secret Mission: Persisted the user's directory choice across app restarts using SharedPreferences, with graceful fallback when access is revoked or the directory no longer exists.

Ready to quiz yourself?