Build a GitHub-Synced Notes App

Build a Flutter desktop notes app that syncs markdown files to GitHub.

Introduction

30 Second Summary

You probably have notes scattered across multiple apps and devices. What if you could build your own notes app that automatically backs up everything to a place you already trust?

In this project, you will build a Linux desktop notes app with Flutter that syncs your notes as markdown files to a GitHub repository using the REST API.

What You'll Build

A two-screen Flutter desktop app called GitNotes that creates, edits, and syncs markdown notes to a private GitHub repository via the Contents API.

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

  • A working Flutter Linux desktop app with a notes list screen and a note editor screen.
  • A GitHub service class that handles creating, reading, and updating files through authenticated REST API calls.
  • A private GitHub repository containing your synced markdown notes, accessible from any device.
  • Secret Mission: Add delete functionality and pull-to-refresh so notes edited from another device sync back into the app.

Are there any prerequisites?

You should have basic programming experience (Python is fine). You also need a GitHub account and a Linux machine with Flutter SDK already installed. The project will walk you through creating a fine-grained Personal Access Token for GitHub.

Set Up the Project

Before you can build the notes app, you need a Flutter project with the right dependencies in place.

Flutter's CLI scaffolds a full project structure for you in one command. You'll also add the http package, which gives Dart the ability to make HTTP requests to the GitHub API.

In this step, get ready to:
  • Scaffold a Flutter project and confirm it runs on Linux.
  • Add the http package for making API calls to GitHub.
Create the Flutter project

The flutter create command generates a complete project structure with all the platform-specific files Flutter needs to build a Linux desktop app.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (the backtick key next to the number 1).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your Flutter project by running this command:
flutter create git_notes

What does this command do?

This generates a git_notes folder on your Desktop containing the full Flutter project structure. It includes lib/ for your Dart code, pubspec.yaml for dependencies, and platform-specific directories for Linux, Android, and more.

  • Move into the new project folder by running:
cd git_notes
  • Verify the project runs on Linux desktop by running:
flutter run -d linux

You should see the default Flutter counter app appear in a new window. It has a floating action button with a + icon and a counter that increments when you press it.

  • Close the app window to stop the process.

App window didn't appear?

Make sure you ran the command from inside the git_notes folder. Run pwd to confirm your path ends with git_notes.

If you see errors about missing Linux toolchain dependencies, confirm your Flutter installation includes Linux desktop support by running flutter doctor and resolving any issues listed there.

Help me troubleshoot why my Flutter Linux app won't launch.

Add the http dependency

The http package is the official Dart team's library for making HTTP requests. You'll use it to talk to the GitHub API later in the project.

  • In VS Code, press Ctrl+K then Ctrl+O to open the folder picker.
  • Navigate to your Desktop and select the git_notes folder, then click Open.
  • In the Explorer sidebar on the left, click pubspec.yaml to open it.
  • Replace the entire contents of pubspec.yaml with the following code:
name: git_notes
description: A GitHub-synced notes app built with Flutter.
publish_to: 'none'

version: 1.0.0+1

environment:
  sdk: ^3.4.0

dependencies:
  flutter:
    sdk: flutter
  http: ^1.6.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

What does this file do?

  • name and description identify your project.
  • environment: sdk: ^3.4.0 sets the minimum Dart SDK version your project requires.
  • http: ^1.6.0 is the key addition. This pulls in the official Dart HTTP client package you'll use to call the GitHub API.
  • uses-material-design: true enables Material Design icons and components in your app.
  • Save pubspec.yaml by pressing Ctrl+S.
  • Fetch the new dependency by running this command in the terminal:
flutter pub get

You should see output that ends with a message confirming dependencies were resolved successfully.

Seeing a dependency error?

Check that the http: ^1.6.0 line is indented exactly two spaces under dependencies:. YAML is sensitive to indentation.

Make sure you saved the file before running flutter pub get. Unsaved changes won't be picked up.

Help me fix a flutter pub get error in my pubspec.yaml.

✔️ Awesome, I've got everything!

Great. Double check you've saved pubspec.yaml and that flutter pub get completed without errors.

ⓧ I'd like to double check the full code

Here is the complete pubspec.yaml you should have at this point:

name: git_notes
description: A GitHub-synced notes app built with Flutter.
publish_to: 'none'

version: 1.0.0+1

environment:
  sdk: ^3.4.0

dependencies:
  flutter:
    sdk: flutter
  http: ^1.6.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

Your project is scaffolded and ready to go. Next up, you'll build the notes list UI that displays all your notes in a scrollable list.

Build the Notes List UI

Your Flutter project is scaffolded and running on Linux. Now it is time to build the first real screen of your app.

Every notes app needs a home screen that shows your list of notes. In this step, you will replace the default counter demo with a custom MaterialApp shell, then build a notes list page with a ListView, a loading spinner, and a floating action button for creating new notes.

In this step, get ready to:
  • Create the app entry point with a Material Design theme.
  • Build a notes list page with placeholder data.
  • Run the app to confirm the new UI appears.
Create the app shell

Flutter apps start with a MaterialApp widget that defines your theme, title, and home screen. You will replace the default counter app code with a clean entry point for GitNotes.

  • Switch back to VS Code with your git_notes project open.
  • Open lib/main.dart from the Explorer sidebar.
  • Select all the existing code and delete it.
  • Replace it with the following code:
import 'package:flutter/material.dart';
import 'notes_list_page.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'GitNotes',
      theme: ThemeData(
        // Use an indigo seed color for Material 3 theming
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
        useMaterial3: true,
      ),
      home: const NotesListPage(),
    );
  }
}

What does this code do?

This file is the entry point for your Flutter app. runApp() takes a widget and makes it the root of the widget tree.

GitNotesApp is a StatelessWidget that returns a MaterialApp. The MaterialApp sets an indigo color scheme with Material 3 styling, and points to NotesListPage as the home screen. You will build that page next.

  • Save lib/main.dart.

You will not see a change yet because NotesListPage does not exist. The next chunk creates it.

Create the notes list page

The notes list page is a StatefulWidget because it will eventually manage a list of notes that changes over time. For now, you will hardcode placeholder data so you can verify the UI before connecting to the API.

The page uses a NoteFile model class to represent each note. You will create a small file for that model first, then create a placeholder file for the editor page (which you will build in the next step).

  • Create a new file called lib/github_service.dart in the Explorer sidebar.
  • Add the following code to define the NoteFile model:
class NoteFile {
  final String name;
  final String content;
  final String sha;

  NoteFile({required this.name, required this.content, required this.sha});
}

What is NoteFile?

NoteFile is a simple data class that holds three properties: the file name (like shopping-list.md), the text content, and a sha hash that GitHub uses to track file versions.

You will expand this file with the full GitHub service in a later step.

  • Save lib/github_service.dart.
  • Create another new file called lib/note_editor_page.dart in the Explorer sidebar.
  • Leave it empty for now. You will build the editor in the next step. The notes list page imports it, so the file needs to exist.
  • Save lib/note_editor_page.dart.

Now create the notes list page itself.

  • Create a new file called lib/notes_list_page.dart in the Explorer sidebar.
  • Add the following code:
import 'package:flutter/material.dart';
import 'github_service.dart';
import 'note_editor_page.dart';

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

  @override
  State<NotesListPage> createState() => _NotesListPageState();
}

class _NotesListPageState extends State<NotesListPage> {
  // Placeholder service and data for UI testing
  List<NoteFile> _notes = [
    NoteFile(name: 'sample-note.md', content: '', sha: 'placeholder'),
  ];
  bool _isLoading = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('GitNotes'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : _notes.isEmpty
              ? const Center(
                  child: Text(
                    'No notes yet. Tap + to create one!',
                    style: TextStyle(fontSize: 16),
                  ),
                )
              : ListView.builder(
                  itemCount: _notes.length,
                  itemBuilder: (context, index) {
                    final note = _notes[index];
                    return ListTile(
                      leading: const Icon(Icons.note),
                      title: Text(note.name.replaceAll('.md', '')),
                      trailing: const Icon(Icons.chevron_right),
                    );
                  },
                ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        child: const Icon(Icons.add),
      ),
    );
  }
}

What does this page do?

  • _notes holds a hardcoded list of NoteFile objects. Later, this will come from the GitHub API.
  • _isLoading controls whether to show a CircularProgressIndicator spinner or the list.
  • The ternary chain in body picks one of three views: loading spinner, empty state message, or a ListView.builder that renders each note as a ListTile.
  • The FloatingActionButton does nothing yet. You will wire it up to the editor page in the next step.
  • Save lib/notes_list_page.dart.

Seeing red underlines in VS Code?

If you see errors on the import 'note_editor_page.dart' line, make sure you created and saved the empty lib/note_editor_page.dart file. Dart requires imported files to exist, even if they are empty.

If NoteFile shows as undefined, confirm that lib/github_service.dart is saved and contains the class definition.

Help me debug my imports.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here is what each file should look like at this point.

import 'package:flutter/material.dart';
import 'notes_list_page.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'GitNotes',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
        useMaterial3: true,
      ),
      home: const NotesListPage(),
    );
  }
}
class NoteFile {
  final String name;
  final String content;
  final String sha;

  NoteFile({required this.name, required this.content, required this.sha});
}
import 'package:flutter/material.dart';
import 'github_service.dart';
import 'note_editor_page.dart';

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

  @override
  State<NotesListPage> createState() => _NotesListPageState();
}

class _NotesListPageState extends State<NotesListPage> {
  // Placeholder service and data for UI testing
  List<NoteFile> _notes = [
    NoteFile(name: 'sample-note.md', content: '', sha: 'placeholder'),
  ];
  bool _isLoading = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('GitNotes'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : _notes.isEmpty
              ? const Center(
                  child: Text(
                    'No notes yet. Tap + to create one!',
                    style: TextStyle(fontSize: 16),
                  ),
                )
              : ListView.builder(
                  itemCount: _notes.length,
                  itemBuilder: (context, index) {
                    final note = _notes[index];
                    return ListTile(
                      leading: const Icon(Icons.note),
                      title: Text(note.name.replaceAll('.md', '')),
                      trailing: const Icon(Icons.chevron_right),
                    );
                  },
                ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {},
        child: const Icon(Icons.add),
      ),
    );
  }
}

lib/note_editor_page.dart should exist as an empty file.

Test with placeholder data

The placeholder NoteFile in _notes lets you confirm the list UI renders correctly before you connect to the real GitHub API.

  • Run the app on Linux desktop by entering this command in your terminal:
flutter run -d linux
  • Wait for the app window to appear.

You should see a window with an indigo-tinted app bar titled GitNotes, a list item showing sample-note with a note icon and a chevron, and a round + button in the bottom-right corner.

What about the empty state?

To see the empty state message, you can temporarily change the _notes list to an empty list ([]) and hot-reload. You should see "No notes yet. Tap + to create one!" centered on screen.

Change it back to the placeholder data when you are done testing.

App not launching?

Make sure all four files in lib/ are saved (main.dart, github_service.dart, notes_list_page.dart, note_editor_page.dart).

Check your terminal for error messages. A common issue is a typo in an import path. The filenames must match exactly, including underscores.

Help me debug why my Flutter app won't launch.

Your notes list UI is working. Next up, you will build the note editor page so tapping a note or the + button opens a screen where you can write markdown content.

Build the Note Editor

Your notes list UI is looking great. But right now, tapping a note or the FAB does nothing.

Every notes app needs a place to actually write. In this step, you will build an editor page with a title field and a content area, then wire up Flutter's Navigator so the app can move between the list and the editor.

In this step, get ready to:
  • Create a note editor page with title and content fields.
  • Wire navigation so tapping notes or the FAB opens the editor.
  • Test the full navigation flow between the list and editor.
Create the editor page

The editor page is a StatefulWidget because its content changes over time. It needs text controllers to manage the title and content inputs, plus state variables to track saving and loading.

  • Create a new file called note_editor_page.dart inside your lib/ folder by running this command from your git_notes project folder:
touch lib/note_editor_page.dart
  • Open lib/note_editor_page.dart in VS Code and paste the following code:
import 'package:flutter/material.dart';
import 'github_service.dart';

class NoteEditorPage extends StatefulWidget {
  final GitHubService? service;
  final NoteFile? existingNote;

  const NoteEditorPage({
    super.key,
    required this.service,
    this.existingNote,
  });

  @override
  State<NoteEditorPage> createState() => _NoteEditorPageState();
}

class _NoteEditorPageState extends State<NoteEditorPage> {
  // Controllers manage the text inside each input field
  final _titleController = TextEditingController();
  final _contentController = TextEditingController();

  // Track UI states for loading indicators
  bool _isSaving = false;
  bool _isFetchingContent = false;
  String? _currentSha;

  @override
  void initState() {
    super.initState();
    // If editing an existing note, pre-fill the title and store its SHA
    if (widget.existingNote != null) {
      _titleController.text = widget.existingNote!.name.replaceAll('.md', '');
      _currentSha = widget.existingNote!.sha;
    }
  }

  Future<void> _saveNote() async {
    final title = _titleController.text.trim();
    if (title.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Please enter a title')),
      );
      return;
    }
    // Pop back to the list, returning true to signal a refresh is needed
    if (mounted) Navigator.pop(context, true);
  }

  @override
  void dispose() {
    // Always dispose controllers to free memory
    _titleController.dispose();
    _contentController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        // Show different title depending on create vs edit mode
        title: Text(widget.existingNote != null ? 'Edit Note' : 'New Note'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        actions: [
          _isSaving
              ? const Padding(
                  padding: EdgeInsets.all(16.0),
                  child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
                )
              : IconButton(onPressed: _saveNote, icon: const Icon(Icons.save)),
        ],
      ),
      body: _isFetchingContent
          ? const Center(child: CircularProgressIndicator())
          : Padding(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                children: [
                  // Title field is disabled when editing an existing note
                  TextField(
                    controller: _titleController,
                    decoration: const InputDecoration(labelText: 'Title', border: OutlineInputBorder()),
                    enabled: widget.existingNote == null,
                  ),
                  const SizedBox(height: 16),
                  // Content field expands to fill remaining space
                  Expanded(
                    child: TextField(
                      controller: _contentController,
                      decoration: const InputDecoration(
                        labelText: 'Content (Markdown)',
                        border: OutlineInputBorder(),
                        alignLabelWithHint: true,
                      ),
                      maxLines: null,
                      expands: true,
                      textAlignVertical: TextAlignVertical.top,
                    ),
                  ),
                ],
              ),
            ),
    );
  }
}

What does this code do?

This file defines the editor screen. Here are the key pieces:

  • NoteEditorPage accepts a nullable service (not wired yet) and an optional existingNote. When existingNote is null, the editor is in create mode. When it has a value, it is in edit mode.
  • initState() pre-fills the title field and stores the SHA when editing. The SHA is required by GitHub's API to update a file later.
  • _saveNote() validates the title, then pops the page and returns true so the list knows to refresh.
  • The enabled: widget.existingNote == null line locks the title field during editing. Renaming would break the file path on GitHub.
  • Save lib/note_editor_page.dart.

Seeing red squiggles under the imports?

The github_service.dart import references a file you created in the previous step for the NoteFile class. Make sure that file exists in your lib/ folder. If VS Code still shows errors, try restarting the Dart analysis server.

Ask for help: Why am I seeing import errors in note_editor_page.dart?

Wire navigation between pages

Flutter uses a Navigator stack to move between screens. You push a new page onto the stack to show it, and pop it off to go back. The editor will pop with a boolean result so the list knows whether to refresh.

  • Switch back to lib/notes_list_page.dart in VS Code.
  • Find the line bool _isLoading = false; and add the following _openEditor method directly below it:
  void _openEditor({NoteFile? note}) async {
    final result = await Navigator.push<bool>(
      context,
      MaterialPageRoute(
        builder: (context) => NoteEditorPage(
          service: null,
          existingNote: note,
        ),
      ),
    );
    if (result == true) {
      // will trigger reload once service is wired
    }
  }

What does this method do?

Navigator.push<bool> opens the editor page and waits for it to return a value. When the editor calls Navigator.pop(context, true), the result variable receives true. Later, you will use that signal to reload the notes list from GitHub.

Passing service: null is temporary. You will replace it with a real GitHubService instance in Step 5.

  • Now scroll down to the ListView.builder section. Find the ListTile widget and find this line:
                      trailing: const Icon(Icons.chevron_right),
  • Add the following line directly below it:
                      onTap: () => _openEditor(note: note),
  • Next, scroll to the floatingActionButton section and find this line:
        onPressed: () {},
  • Replace it with:
        onPressed: () => _openEditor(),
  • Save lib/notes_list_page.dart.
  • Run the app with flutter run -d linux. Tap the + FAB button. You should see the editor open with the title "New Note" in the AppBar and an empty title field.
  • Press the back arrow to return to the list. Tap the sample-note item. The editor should open with "Edit Note" in the AppBar and the title field pre-filled and disabled.

Editor not opening when you tap?

Make sure the onTap line is inside the ListTile widget, not outside it. The line must come after trailing: and before the closing parenthesis of ListTile.

Also check that the FAB's onPressed calls _openEditor() with no arguments for create mode.

Ask for help: Why is my navigation not working between the list and editor?

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the full lib/note_editor_page.dart:

import 'package:flutter/material.dart';
import 'github_service.dart';

class NoteEditorPage extends StatefulWidget {
  final GitHubService? service;
  final NoteFile? existingNote;

  const NoteEditorPage({
    super.key,
    required this.service,
    this.existingNote,
  });

  @override
  State<NoteEditorPage> createState() => _NoteEditorPageState();
}

class _NoteEditorPageState extends State<NoteEditorPage> {
  final _titleController = TextEditingController();
  final _contentController = TextEditingController();
  bool _isSaving = false;
  bool _isFetchingContent = false;
  String? _currentSha;

  @override
  void initState() {
    super.initState();
    if (widget.existingNote != null) {
      _titleController.text = widget.existingNote!.name.replaceAll('.md', '');
      _currentSha = widget.existingNote!.sha;
    }
  }

  Future<void> _saveNote() async {
    final title = _titleController.text.trim();
    if (title.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Please enter a title')),
      );
      return;
    }
    if (mounted) Navigator.pop(context, true);
  }

  @override
  void dispose() {
    _titleController.dispose();
    _contentController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.existingNote != null ? 'Edit Note' : 'New Note'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        actions: [
          _isSaving
              ? const Padding(
                  padding: EdgeInsets.all(16.0),
                  child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
                )
              : IconButton(onPressed: _saveNote, icon: const Icon(Icons.save)),
        ],
      ),
      body: _isFetchingContent
          ? const Center(child: CircularProgressIndicator())
          : Padding(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                children: [
                  TextField(
                    controller: _titleController,
                    decoration: const InputDecoration(labelText: 'Title', border: OutlineInputBorder()),
                    enabled: widget.existingNote == null,
                  ),
                  const SizedBox(height: 16),
                  Expanded(
                    child: TextField(
                      controller: _contentController,
                      decoration: const InputDecoration(
                        labelText: 'Content (Markdown)',
                        border: OutlineInputBorder(),
                        alignLabelWithHint: true,
                      ),
                      maxLines: null,
                      expands: true,
                      textAlignVertical: TextAlignVertical.top,
                    ),
                  ),
                ],
              ),
            ),
    );
  }
}

Here is the full lib/notes_list_page.dart:

import 'package:flutter/material.dart';
import 'github_service.dart';
import 'note_editor_page.dart';

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

  @override
  State<NotesListPage> createState() => _NotesListPageState();
}

class _NotesListPageState extends State<NotesListPage> {
  List<NoteFile> _notes = [
    NoteFile(name: 'sample-note.md', content: '', sha: 'placeholder'),
  ];
  bool _isLoading = false;

  void _openEditor({NoteFile? note}) async {
    final result = await Navigator.push<bool>(
      context,
      MaterialPageRoute(
        builder: (context) => NoteEditorPage(
          service: null,
          existingNote: note,
        ),
      ),
    );
    if (result == true) {
      // will trigger reload once service is wired
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('GitNotes'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : _notes.isEmpty
              ? const Center(
                  child: Text(
                    'No notes yet. Tap + to create one!',
                    style: TextStyle(fontSize: 16),
                  ),
                )
              : ListView.builder(
                  itemCount: _notes.length,
                  itemBuilder: (context, index) {
                    final note = _notes[index];
                    return ListTile(
                      leading: const Icon(Icons.note),
                      title: Text(note.name.replaceAll('.md', '')),
                      trailing: const Icon(Icons.chevron_right),
                      onTap: () => _openEditor(note: note),
                    );
                  },
                ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _openEditor(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

Your app now has two working screens with navigation between them. Next up, you will build the GitHub service that handles reading and writing notes through the API.

Build the GitHub Service

Your app has two screens and navigation, but right now everything runs on placeholder data. To turn GitNotes into a real app, you need a way to talk to GitHub and store notes as actual files in your repository.

In this step, you will build a dedicated service class that handles all communication with the GitHub REST API. This class will know how to list files, read their contents, and create or update them.

In this step, get ready to:
  • Create a data model and service class with GitHub API authentication.
  • Implement four methods for listing, reading, creating, and updating notes.
  • Understand how the SHA requirement prevents overwriting changes.
Create the service class

The GitHub Contents API needs three pieces of information for every request: your personal access token, your GitHub username, and the repository name. You will wrap these into a service class that every page can use.

  • Create a new file called lib/github_service.dart in your git_notes project.
  • Add the following code to set up the data model and class skeleton:
import 'dart:convert';
import 'package:http/http.dart' as http;

// Data model representing a single note file in the repo
class NoteFile {
  final String name;
  final String content;
  final String sha;

  NoteFile({required this.name, required this.content, required this.sha});
}

// Service class that handles all GitHub API communication
class GitHubService {
  final String token;
  final String owner;
  final String repo;

  GitHubService({
    required this.token,
    required this.owner,
    required this.repo,
  });

  // Auth headers required by every GitHub API request
  Map<String, String> get _headers => {
        'Authorization': 'Bearer $token',
        'Accept': 'application/vnd.github+json',
        'X-GitHub-Api-Version': '2022-11-28',
      };

  // Base URL for the Contents API endpoint
  String get _baseUrl =>
      'https://api.github.com/repos/$owner/$repo/contents';
}

What does this code do?

NoteFile is a simple data model with three fields: the file's name (like my-note.md), its text content, and a sha hash that GitHub uses to track file versions.

GitHubService stores your credentials and exposes two getters. The _headers getter builds the authentication headers that every API call needs. The _baseUrl getter constructs the GitHub Contents API endpoint for your repository.

  • Save lib/github_service.dart.
  • Check that the file appears in your VS Code Explorer sidebar with no red underlines in the editor.

Seeing red underlines?

Make sure you saved the file inside the lib/ folder, not the project root. Dart expects library files to live in lib/.

If the http import shows an error, confirm you ran flutter pub get after adding the http package in the previous step.

Help me fix a Dart import error in my github_service.dart file.

Implement the API methods

With the skeleton in place, you can now add the methods that actually talk to GitHub. Each method maps to one operation in the Contents API.

  • In lib/github_service.dart, add the fetchNotes() method inside the GitHubService class, below the _baseUrl getter:
  // GET the repo root to list all .md files
  Future<List<NoteFile>> fetchNotes() async {
    final response = await http.get(
      Uri.parse(_baseUrl),
      headers: _headers,
    );

    if (response.statusCode == 200) {
      final List<dynamic> files = jsonDecode(response.body);
      final List<NoteFile> notes = [];

      for (final file in files) {
        if (file['name'].toString().endsWith('.md')) {
          notes.add(NoteFile(
            name: file['name'],
            content: '',
            sha: file['sha'],
          ));
        }
      }
      return notes;
    } else if (response.statusCode == 404) {
      // Empty repo returns 404, treat as no notes
      return [];
    } else {
      throw Exception('Failed to fetch notes: ${response.statusCode}');
    }
  }

What does this method do?

  • It sends a GET request to the repo root, which returns a JSON array of all files.
  • It filters for files ending in .md so your notes list only shows markdown notes.
  • An empty repository returns a 404 from GitHub, so the method handles that by returning an empty list instead of crashing.
  • Save the file and confirm no red underlines appear on the new method.

Red underline on jsonDecode?

The jsonDecode function comes from dart:convert. Make sure your file starts with import 'dart:convert'; at the top.

Help me fix an unresolved reference to jsonDecode in my Dart file.

  • Next, add the fetchNote() method below fetchNotes():
  // GET a single file and decode its base64 content
  Future<NoteFile> fetchNote(String fileName) async {
    final response = await http.get(
      Uri.parse('$_baseUrl/$fileName'),
      headers: _headers,
    );

    if (response.statusCode == 200) {
      final data = jsonDecode(response.body);
      // GitHub returns content with newlines every 60 chars, strip them
      final encodedContent = data['content'].toString().replaceAll('\n', '');
      final content = utf8.decode(base64Decode(encodedContent));
      return NoteFile(
        name: data['name'],
        content: content,
        sha: data['sha'],
      );
    } else {
      throw Exception('Failed to fetch note: ${response.statusCode}');
    }
  }

Why the Base64 decoding?

The GitHub Contents API returns file content as a Base64-encoded string. This is a standard way to safely transmit binary data over JSON.

GitHub also inserts newline characters every 60 characters in the encoded string. The replaceAll('\n', '') call strips those before decoding back to readable text.

  • Save and confirm no errors.
  • Finally, add the createNote() and updateNote() methods below fetchNote():
  // PUT a new file with base64-encoded content
  Future<void> createNote(String fileName, String content) async {
    final response = await http.put(
      Uri.parse('$_baseUrl/$fileName'),
      headers: _headers,
      body: jsonEncode({
        'message': 'Create $fileName',
        'content': base64Encode(utf8.encode(content)),
      }),
    );

    if (response.statusCode != 201) {
      throw Exception('Failed to create note: ${response.statusCode}');
    }
  }

  // PUT an updated file, including the existing SHA
  Future<void> updateNote(String fileName, String content, String sha) async {
    final response = await http.put(
      Uri.parse('$_baseUrl/$fileName'),
      headers: _headers,
      body: jsonEncode({
        'message': 'Update $fileName',
        'content': base64Encode(utf8.encode(content)),
        'sha': sha,
      }),
    );

    if (response.statusCode != 200) {
      throw Exception('Failed to update note: ${response.statusCode}');
    }
  }

What do these methods do?

  • Both use PUT requests to the Contents API. GitHub uses PUT for both creating and updating files at a given path.
  • createNote() encodes the note content to Base64 and sends it with a commit message. A successful create returns status 201.
  • updateNote() does the same but also includes the sha field. GitHub requires this to confirm you are updating the correct version of the file. A successful update returns status 200.
  • Save lib/github_service.dart.
  • Verify the entire file compiles by running this in your terminal:
flutter analyze

You should see No issues found! in the output.

Seeing analysis issues?

Check that every method is inside the GitHubService class (between the opening { after the class declaration and the final closing }).

A common mistake is placing a method after the class's closing brace. Make sure you have exactly one } at the very end of the file to close the class.

Help me fix flutter analyze errors in my github_service.dart file.

Understand the SHA requirement

Why does updateNote need a SHA?

Every file in a GitHub repository has a unique SHA hash that identifies its current version. When you ask GitHub to update a file, you must send that file's current SHA along with the new content.

This prevents accidental overwrites. If someone else (or you from another device) changed the file since you last read it, the SHA you have will be outdated and GitHub will reject the update with a 409 conflict error.

Your app handles this by fetching the current SHA when opening a note for editing, then passing that same SHA back when saving. This guarantees you are always updating the version you actually read.

✔️ Awesome, I've got everything!

Great. Double check you have saved lib/github_service.dart and that flutter analyze shows no issues.

ⓧ I'd like to double check the full code

Here is the complete lib/github_service.dart file:

import 'dart:convert';
import 'package:http/http.dart' as http;

class NoteFile {
  final String name;
  final String content;
  final String sha;

  NoteFile({required this.name, required this.content, required this.sha});
}

class GitHubService {
  final String token;
  final String owner;
  final String repo;

  GitHubService({
    required this.token,
    required this.owner,
    required this.repo,
  });

  Map<String, String> get _headers => {
        'Authorization': 'Bearer $token',
        'Accept': 'application/vnd.github+json',
        'X-GitHub-Api-Version': '2022-11-28',
      };

  String get _baseUrl =>
      'https://api.github.com/repos/$owner/$repo/contents';

  Future<List<NoteFile>> fetchNotes() async {
    final response = await http.get(
      Uri.parse(_baseUrl),
      headers: _headers,
    );

    if (response.statusCode == 200) {
      final List<dynamic> files = jsonDecode(response.body);
      final List<NoteFile> notes = [];

      for (final file in files) {
        if (file['name'].toString().endsWith('.md')) {
          notes.add(NoteFile(
            name: file['name'],
            content: '',
            sha: file['sha'],
          ));
        }
      }
      return notes;
    } else if (response.statusCode == 404) {
      return [];
    } else {
      throw Exception('Failed to fetch notes: ${response.statusCode}');
    }
  }

  Future<NoteFile> fetchNote(String fileName) async {
    final response = await http.get(
      Uri.parse('$_baseUrl/$fileName'),
      headers: _headers,
    );

    if (response.statusCode == 200) {
      final data = jsonDecode(response.body);
      final encodedContent = data['content'].toString().replaceAll('\n', '');
      final content = utf8.decode(base64Decode(encodedContent));
      return NoteFile(
        name: data['name'],
        content: content,
        sha: data['sha'],
      );
    } else {
      throw Exception('Failed to fetch note: ${response.statusCode}');
    }
  }

  Future<void> createNote(String fileName, String content) async {
    final response = await http.put(
      Uri.parse('$_baseUrl/$fileName'),
      headers: _headers,
      body: jsonEncode({
        'message': 'Create $fileName',
        'content': base64Encode(utf8.encode(content)),
      }),
    );

    if (response.statusCode != 201) {
      throw Exception('Failed to create note: ${response.statusCode}');
    }
  }

  Future<void> updateNote(String fileName, String content, String sha) async {
    final response = await http.put(
      Uri.parse('$_baseUrl/$fileName'),
      headers: _headers,
      body: jsonEncode({
        'message': 'Update $fileName',
        'content': base64Encode(utf8.encode(content)),
        'sha': sha,
      }),
    );

    if (response.statusCode != 200) {
      throw Exception('Failed to update note: ${response.statusCode}');
    }
  }
}

Your service class is ready to handle all communication with GitHub. Next up, you will wire this service into both pages so your app actually creates and reads real notes.

Wire Everything Together

You now have a service that talks to GitHub and UI pages that look right but don't do anything real. The final piece is connecting these two layers so the app fetches, creates, and updates notes through the GitHub API.

In this step, you will replace the placeholder data in your list page with real API calls. You will also give the editor the ability to load, create, and update notes on GitHub.

In this step, get ready to:
  • Connect the notes list page to GitHubService so it fetches real notes.
  • Connect the editor page to the service so saving writes to GitHub.
  • Test the full create-edit-refresh flow against your live repository.
Connect the list page to the service

Right now, notes_list_page.dart displays hardcoded placeholder data and passes null as the service to the editor. You need to instantiate a real GitHubService and call its fetchNotes() method when the page loads.

  • Replace the entire contents of lib/notes_list_page.dart with the following code:
import 'package:flutter/material.dart';
import 'github_service.dart';
import 'note_editor_page.dart';

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

  @override
  State<NotesListPage> createState() => _NotesListPageState();
}

class _NotesListPageState extends State<NotesListPage> {
  // Create a GitHubService instance with your credentials
  final GitHubService _service = GitHubService(
    token: 'your-github-token-here',
    owner: 'your-github-username-here',
    repo: 'my-notes',
  );

  // Start with an empty list and loading set to true
  List<NoteFile> _notes = [];
  bool _isLoading = true;

  @override
  void initState() {
    super.initState();
    // Fetch notes from GitHub as soon as the page loads
    _loadNotes();
  }

  // Async method to fetch all notes from the repository
  Future<void> _loadNotes() async {
    setState(() => _isLoading = true);
    try {
      final notes = await _service.fetchNotes();
      setState(() {
        _notes = notes;
        _isLoading = false;
      });
    } catch (e) {
      setState(() => _isLoading = false);
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Error loading notes: $e')),
        );
      }
    }
  }

  // Navigate to the editor and refresh the list when returning
  void _openEditor({NoteFile? note}) async {
    final result = await Navigator.push<bool>(
      context,
      MaterialPageRoute(
        builder: (context) => NoteEditorPage(
          service: _service,
          existingNote: note,
        ),
      ),
    );
    if (result == true) {
      _loadNotes();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('GitNotes'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : _notes.isEmpty
              ? const Center(
                  child: Text(
                    'No notes yet. Tap + to create one!',
                    style: TextStyle(fontSize: 16),
                  ),
                )
              : ListView.builder(
                  itemCount: _notes.length,
                  itemBuilder: (context, index) {
                    final note = _notes[index];
                    return ListTile(
                      leading: const Icon(Icons.note),
                      title: Text(note.name.replaceAll('.md', '')),
                      trailing: const Icon(Icons.chevron_right),
                      onTap: () => _openEditor(note: note),
                    );
                  },
                ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _openEditor(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

What does this code do?

  • _service creates a GitHubService instance that knows your token, username, and repo name. Every API call goes through this single instance.
  • initState() calls _loadNotes() immediately when the page first appears. This triggers the loading spinner and fetches notes from GitHub.
  • _loadNotes() wraps the API call in a try/catch. On success, it updates _notes with real data. On failure, it shows a SnackBar error message.
  • _openEditor() now passes _service to the editor page. When the editor pops with true, the list refreshes to show any newly created or updated notes.
  • Replace 'your-github-token-here' with your Personal Access Token from Step 1.
  • Replace 'your-github-username-here' with your GitHub username.
  • Replace 'my-notes' with the name of your private repository if you chose a different name.
  • Save lib/notes_list_page.dart.

Seeing red underlines in VS Code?

You will see an error on the NoteEditorPage line because the editor page still expects a nullable service (GitHubService?). This is expected and will resolve in the next substep when you update the editor.

If you see other errors, check that your import statements at the top match the three shown in the code above.

help me fix errors in my notes_list_page.dart file

Connect the editor to the service

The editor page needs two capabilities: loading an existing note's content when editing, and calling the correct API method (create or update) when saving.

  • Replace the entire contents of lib/note_editor_page.dart with the following code:
import 'package:flutter/material.dart';
import 'github_service.dart';

class NoteEditorPage extends StatefulWidget {
  // Service is now non-nullable - always required
  final GitHubService service;
  final NoteFile? existingNote;

  const NoteEditorPage({
    super.key,
    required this.service,
    this.existingNote,
  });

  @override
  State<NoteEditorPage> createState() => _NoteEditorPageState();
}

class _NoteEditorPageState extends State<NoteEditorPage> {
  final _titleController = TextEditingController();
  final _contentController = TextEditingController();
  bool _isSaving = false;
  bool _isFetchingContent = false;
  String? _currentSha;

  @override
  void initState() {
    super.initState();
    if (widget.existingNote != null) {
      _titleController.text =
          widget.existingNote!.name.replaceAll('.md', '');
      _currentSha = widget.existingNote!.sha;
      // Fetch the full content from GitHub when editing
      _fetchContent();
    }
  }

  // Load the note's content and latest SHA from GitHub
  Future<void> _fetchContent() async {
    setState(() => _isFetchingContent = true);
    try {
      final note =
          await widget.service.fetchNote(widget.existingNote!.name);
      _contentController.text = note.content;
      _currentSha = note.sha;
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Error loading note: $e')),
        );
      }
    }
    setState(() => _isFetchingContent = false);
  }

  // Save the note - create new or update existing
  Future<void> _saveNote() async {
    final title = _titleController.text.trim();
    final content = _contentController.text;

    if (title.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Please enter a title')),
      );
      return;
    }

    setState(() => _isSaving = true);

    try {
      final fileName = '$title.md';
      // If editing an existing note, update it with the SHA
      if (widget.existingNote != null && _currentSha != null) {
        await widget.service.updateNote(fileName, content, _currentSha!);
      } else {
        // Otherwise create a brand new file
        await widget.service.createNote(fileName, content);
      }
      if (mounted) {
        Navigator.pop(context, true);
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Error saving note: $e')),
        );
      }
    }

    setState(() => _isSaving = false);
  }

  @override
  void dispose() {
    _titleController.dispose();
    _contentController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(
            widget.existingNote != null ? 'Edit Note' : 'New Note'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        actions: [
          _isSaving
              ? const Padding(
                  padding: EdgeInsets.all(16.0),
                  child: SizedBox(
                    width: 20,
                    height: 20,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  ),
                )
              : IconButton(
                  onPressed: _saveNote,
                  icon: const Icon(Icons.save),
                ),
        ],
      ),
      body: _isFetchingContent
          ? const Center(child: CircularProgressIndicator())
          : Padding(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                children: [
                  TextField(
                    controller: _titleController,
                    decoration: const InputDecoration(
                      labelText: 'Title',
                      border: OutlineInputBorder(),
                    ),
                    enabled: widget.existingNote == null,
                  ),
                  const SizedBox(height: 16),
                  Expanded(
                    child: TextField(
                      controller: _contentController,
                      decoration: const InputDecoration(
                        labelText: 'Content (Markdown)',
                        border: OutlineInputBorder(),
                        alignLabelWithHint: true,
                      ),
                      maxLines: null,
                      expands: true,
                      textAlignVertical: TextAlignVertical.top,
                    ),
                  ),
                ],
              ),
            ),
    );
  }
}

What does this code do?

  • The service field is now non-nullable (GitHubService instead of GitHubService?). The editor always receives a working service from the list page.
  • _fetchContent() calls fetchNote() to download the note's full markdown content and its latest SHA. The SHA is critical because GitHub requires it when updating a file.
  • _saveNote() checks whether you are editing (has existingNote and _currentSha) or creating. It calls updateNote() or createNote() accordingly, then pops the page with true to signal the list page to refresh.

Why does the SHA matter?

The GitHub Contents API uses the SHA as a concurrency guard. When you send an update request, you include the SHA of the file version you are replacing. If someone (or another device) changed the file since you last fetched it, the SHA won't match and GitHub rejects the update with a 409 Conflict. This prevents you from accidentally overwriting newer changes.

  • Save lib/note_editor_page.dart.
  • Confirm that VS Code no longer shows red underlines in either file.

Still seeing compile errors?

Make sure both files are saved. The most common issue is a mismatch between the service type: notes_list_page.dart passes a non-nullable GitHubService, so note_editor_page.dart must declare final GitHubService service (no question mark).

help me debug compile errors in my Flutter notes app

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on to testing.

ⓧ I'd like to double check the full code

Here is the complete lib/notes_list_page.dart:

import 'package:flutter/material.dart';
import 'github_service.dart';
import 'note_editor_page.dart';

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

  @override
  State<NotesListPage> createState() => _NotesListPageState();
}

class _NotesListPageState extends State<NotesListPage> {
  final GitHubService _service = GitHubService(
    token: 'your-github-token-here',
    owner: 'your-github-username-here',
    repo: 'my-notes',
  );

  List<NoteFile> _notes = [];
  bool _isLoading = true;

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

  Future<void> _loadNotes() async {
    setState(() => _isLoading = true);
    try {
      final notes = await _service.fetchNotes();
      setState(() {
        _notes = notes;
        _isLoading = false;
      });
    } catch (e) {
      setState(() => _isLoading = false);
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Error loading notes: $e')),
        );
      }
    }
  }

  void _openEditor({NoteFile? note}) async {
    final result = await Navigator.push<bool>(
      context,
      MaterialPageRoute(
        builder: (context) => NoteEditorPage(
          service: _service,
          existingNote: note,
        ),
      ),
    );
    if (result == true) {
      _loadNotes();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('GitNotes'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : _notes.isEmpty
              ? const Center(
                  child: Text(
                    'No notes yet. Tap + to create one!',
                    style: TextStyle(fontSize: 16),
                  ),
                )
              : ListView.builder(
                  itemCount: _notes.length,
                  itemBuilder: (context, index) {
                    final note = _notes[index];
                    return ListTile(
                      leading: const Icon(Icons.note),
                      title: Text(note.name.replaceAll('.md', '')),
                      trailing: const Icon(Icons.chevron_right),
                      onTap: () => _openEditor(note: note),
                    );
                  },
                ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _openEditor(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

And here is the complete lib/note_editor_page.dart:

import 'package:flutter/material.dart';
import 'github_service.dart';

class NoteEditorPage extends StatefulWidget {
  final GitHubService service;
  final NoteFile? existingNote;

  const NoteEditorPage({
    super.key,
    required this.service,
    this.existingNote,
  });

  @override
  State<NoteEditorPage> createState() => _NoteEditorPageState();
}

class _NoteEditorPageState extends State<NoteEditorPage> {
  final _titleController = TextEditingController();
  final _contentController = TextEditingController();
  bool _isSaving = false;
  bool _isFetchingContent = false;
  String? _currentSha;

  @override
  void initState() {
    super.initState();
    if (widget.existingNote != null) {
      _titleController.text =
          widget.existingNote!.name.replaceAll('.md', '');
      _currentSha = widget.existingNote!.sha;
      _fetchContent();
    }
  }

  Future<void> _fetchContent() async {
    setState(() => _isFetchingContent = true);
    try {
      final note =
          await widget.service.fetchNote(widget.existingNote!.name);
      _contentController.text = note.content;
      _currentSha = note.sha;
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Error loading note: $e')),
        );
      }
    }
    setState(() => _isFetchingContent = false);
  }

  Future<void> _saveNote() async {
    final title = _titleController.text.trim();
    final content = _contentController.text;

    if (title.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Please enter a title')),
      );
      return;
    }

    setState(() => _isSaving = true);

    try {
      final fileName = '$title.md';
      if (widget.existingNote != null && _currentSha != null) {
        await widget.service.updateNote(fileName, content, _currentSha!);
      } else {
        await widget.service.createNote(fileName, content);
      }
      if (mounted) {
        Navigator.pop(context, true);
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Error saving note: $e')),
        );
      }
    }

    setState(() => _isSaving = false);
  }

  @override
  void dispose() {
    _titleController.dispose();
    _contentController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(
            widget.existingNote != null ? 'Edit Note' : 'New Note'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        actions: [
          _isSaving
              ? const Padding(
                  padding: EdgeInsets.all(16.0),
                  child: SizedBox(
                    width: 20,
                    height: 20,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  ),
                )
              : IconButton(
                  onPressed: _saveNote,
                  icon: const Icon(Icons.save),
                ),
        ],
      ),
      body: _isFetchingContent
          ? const Center(child: CircularProgressIndicator())
          : Padding(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                children: [
                  TextField(
                    controller: _titleController,
                    decoration: const InputDecoration(
                      labelText: 'Title',
                      border: OutlineInputBorder(),
                    ),
                    enabled: widget.existingNote == null,
                  ),
                  const SizedBox(height: 16),
                  Expanded(
                    child: TextField(
                      controller: _contentController,
                      decoration: const InputDecoration(
                        labelText: 'Content (Markdown)',
                        border: OutlineInputBorder(),
                        alignLabelWithHint: true,
                      ),
                      maxLines: null,
                      expands: true,
                      textAlignVertical: TextAlignVertical.top,
                    ),
                  ),
                ],
              ),
            ),
    );
  }
}
Test the full flow

Everything is connected. Time to run the app and verify that notes round-trip through your GitHub repository.

  • Run the app by executing this command:
flutter run -d linux
  • The app should launch and show either a loading spinner followed by the empty state message, or your notes if you already have .md files in the repo.
  • Tap the + floating action button to open the editor.
  • Enter a title like My First Note and some markdown content in the body.
  • Tap the save icon in the top-right corner.
  • The editor should close and your note should appear in the list.
  • Open your browser and navigate to your GitHub repository.
  • You should see a new file called My First Note.md with the content you typed.
  • Back in the app, tap the note to open it in edit mode.
  • Change the content and tap save again.
  • Refresh your GitHub repository page. The file should show your updated content and a second commit in the history.

Seeing an error SnackBar when the app launches?

  • Double check that your token, owner, and repo values in _service are correct. A typo in any of the three will cause a 404 or 401 error.
  • Make sure your Personal Access Token has not expired and has Contents set to Read and write under Repository permissions.
  • Confirm that your repository is completely empty (no README or .gitignore). An initial commit from GitHub can sometimes cause unexpected responses.

help me debug why my GitNotes app can't connect to GitHub

What should I see on GitHub?

Each note you create appears as a separate .md file in your repository's root. Each save produces a commit with a message like "Create My First Note.md" or "Update My First Note.md". You can click any file to view its rendered markdown.

Your notes app is fully functional. Every note you write syncs to GitHub as a markdown file, and reopening the app pulls them all back. Next up, the Secret Mission challenges you to add delete functionality and pull-to-refresh for true cross-device sync.

Secret mission

Add Delete and Pull-to-Refresh

Your notes app can create and edit, but what about deleting notes you no longer need? And what happens when you edit a note directly on GitHub from another device? In this mission, you will add delete functionality with a long-press gesture and pull-to-refresh so your app stays in sync no matter where you edit.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses GitHub's free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • git_notes Flutter project folder (local).
  • my-notes private GitHub repository.
  • Fine-grained Personal Access Token (flutter-notes-app).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep using your notes app.

  • Your my-notes repository stays on GitHub and continues to store your notes for free.
  • Your Flutter project folder remains on your Desktop, ready to run anytime with flutter run -d linux.
  • Your Personal Access Token remains active until its expiration date.

Pause - I'll come back to this later

Shut down any running processes to free up system resources. Your files and repository stay intact for when you return.

  • Close the Flutter app window if it is still running.
  • In your terminal, press Ctrl+C to stop the Flutter development process if it is still active.
  • Your git_notes folder and my-notes repository will be waiting for you when you come back.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to clean everything up.

Delete the local project folder:

  • Remove the git_notes folder by running:
cd ~/Desktop
rm -rf git_notes

Delete the GitHub repository:

  • Go to your my-notes repository on GitHub.
  • Click Settings (under the repository name).
  • Scroll down to the Danger Zone section and click Delete this repository.
  • Confirm by typing the repository name when prompted, then click Delete this repository again.

Revoke the Personal Access Token:

  • On GitHub, click your profile picture in the top-right corner, then click Settings.
  • In the left sidebar, click Developer settings.
  • Under Personal access tokens, click Fine-grained tokens.
  • Find the flutter-notes-app token and click Delete.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully functional Linux desktop notes app that syncs your markdown files to GitHub in real time.

You've learned how to:

  • Built a Flutter Linux desktop app with two-screen navigation using widget composition (Scaffold, AppBar, ListView, TextField, FloatingActionButton).
  • Used Dart's http package to make authenticated REST API calls to GitHub's Contents API, handling Base64 encoding/decoding for the API's content format.
  • Managed async state (loading, error, success) in a StatefulWidget using setState to trigger UI rebuilds.
  • Secret Mission: Implemented delete operations with HTTP DELETE bodies and pull-to-refresh for cross-device sync.

Ready to quiz yourself?