Build an AI-Powered Save-and-Act App
Build Recall: a full-stack app that saves links and screenshots with AI.
Introduction
30 Second Summary
Everyone saves things. Screenshots of restaurants, links to articles, product pages to buy later. The problem is that most of those saves disappear into a black hole of browser tabs and camera rolls, never to be acted on.
In this project, you will build Recall, a full-stack app that saves links and screenshots, uses AI to figure out why you saved them, organizes them automatically, and nudges you daily to follow through.
What You'll Build
A cross-platform "save and act" app with a Spring Boot REST API backend, a PostgreSQL database, Gemini AI integration for automatic categorization, and a .NET MAUI frontend running natively on Mac and iOS.
By the end of this project, you'll have:
- A working Spring Boot REST API with PostgreSQL that accepts links and screenshots, analyzes them with Gemini AI to determine intent, and organizes saves automatically.
- A native .NET MAUI app running on both Mac and iOS that displays saves in a clean UI with share extension support.
- A complete save-analyze-notify-complete loop: saves come in, AI categorizes them, daily digest notifications remind you, and you mark them complete when done.
- Secret Mission: Add screenshot folder auto-scanning that watches for new screenshots and automatically imports them into Recall.
Are there any prerequisites?
This project assumes you are semi-comfortable with C# and Java. You should have JDK 21+, Rider, and IntelliJ IDEA already installed on your machine.
You will also need an Apple Developer account for building to Mac Catalyst and iOS. If you do not have one yet, you can sign up for free at developer.apple.com.
Set Up Backend Tools
Recall needs two foundational pieces on the backend: a database to store saves and a framework to serve the REST API.
In this step, you will install Docker Desktop to run a PostgreSQL database locally. You will also scaffold a Spring Boot project using Spring Initializr and wire it up to the database.
In this step, get ready to:
- Install Docker Desktop and pull the PostgreSQL image.
- Create a new Spring Boot project with the required dependencies.
- Start a PostgreSQL container and connect it to your Spring Boot app.
Install Docker Desktop and pull PostgreSQL
Docker Desktop lets you run containers on your machine. You will use it to host a PostgreSQL database without installing PostgreSQL directly.
- Check if Docker is already installed by running this command in your terminal:
docker --version
✔️ I see a version number
Docker is ready. Make sure Docker Desktop is running (you should see the Docker whale icon in your menu bar or system tray) before continuing.
ⓧ Command not found
Docker is not installed yet. Follow these steps to install it.
- Open https://www.docker.com/products/docker-desktop/ in your browser.
- Download the installer for your platform (macOS Apple chip, macOS Intel, or Windows).
- Run the installer and follow the on-screen prompts to complete the installation.
- Launch Docker Desktop once the install finishes.
- Wait for the Docker whale icon to appear in your menu bar (macOS) or system tray (Windows). This means Docker is ready.
- Run docker --version again to confirm it works.
Still seeing command not found?
Close and reopen your terminal after installation. Docker Desktop must be running (not just installed) for the CLI to work.
Help me troubleshoot Docker
- Pull the PostgreSQL 17 image by running this command:
docker pull postgres:17
What does this do?
This downloads the official PostgreSQL 17 image from Docker Hub to your machine. When you start a container later, Docker uses this local image instead of downloading it again.
- You should see download progress bars followed by a message like Status: Downloaded newer image for postgres:17.
Pull command hanging or failing?
Make sure Docker Desktop is running. The whale icon must be visible and not animating (animating means it is still starting up).
If you see a network error, check your internet connection and try again.
Help me fix the Docker pull error
Create the Spring Boot project
Spring Initializr generates a ready-to-run project with your chosen dependencies already wired in. You will use it to scaffold the Recall backend.
- Confirm your JDK version by running this command:
java --version
- You should see 21 or higher in the output.
- Open https://start.spring.io in your browser.
- Set Project to Maven.
- Set Language to Java.
- Set Spring Boot version to 4.0.6.
- Under Project Metadata, set Group to com.recall.
- Set Artifact to recall-api.
- Set Name to recall-api.
- Set Description to Recall - AI-powered save and act backend.
- Set Packaging to Jar.
- Set Java to 21.
- Click Add Dependencies and add each of the following one at a time:
- Spring Web.
- Spring Data JPA.
- PostgreSQL Driver.
- Spring Security.
- Validation.
- Click Generate to download the project as a zip file.
- Extract the zip file to your Desktop.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type IntelliJ IDEA and press Enter to open it.
- Click File, then Open.
- Select the recall-api folder you just extracted and click Open.
- If IntelliJ asks to trust the project, click Trust Project.
What is Spring Initializr?
Spring Initializr is a web tool that generates a preconfigured Spring Boot project. It creates the pom.xml with your chosen dependencies, a Maven wrapper so you can build without installing Maven, and the basic application class.
- Wait for IntelliJ to finish indexing and importing dependencies. You will see a progress bar at the bottom of the window.
- Once indexing completes, expand src/main/java/com/recall in the Project sidebar. You should see RecallApiApplication.java.
IntelliJ showing red underlines or import errors?
Wait for the Maven import to complete. The progress bar at the bottom must finish before dependencies resolve.
If errors persist, right-click the pom.xml in the Project sidebar and select Maven then Reload Project.
Help me resolve IntelliJ import errors
Start PostgreSQL and configure the connection
Now you will define a Docker Compose file that tells Docker how to run your PostgreSQL container. Then you will configure Spring Boot to connect to it.
- In IntelliJ, right-click the recall-api root folder in the Project sidebar.
- Select New, then File.
- Name it docker-compose.yml and press Enter.
- Paste the following into docker-compose.yml:
services:
postgres:
image: postgres:17
container_name: recall-postgres
environment:
POSTGRES_DB: recall
POSTGRES_USER: recall
POSTGRES_PASSWORD: recall-dev-password
ports:
- "5432:5432"
volumes:
- recall-data:/var/lib/postgresql/data
volumes:
recall-data:
What does this file do?
This tells Docker Compose to run a PostgreSQL 17 container named recall-postgres. It creates a database called recall with username recall and password recall-dev-password.
Port 5432 is mapped from the container to your machine so Spring Boot can connect at localhost:5432. The volume recall-data ensures your data persists even if the container restarts.
- Save docker-compose.yml.
- Now configure Spring Boot to connect to this database. Open src/main/resources/application.properties in IntelliJ.
- Replace the entire contents with the following:
spring.application.name=recall-api
server.port=8080
spring.datasource.url=jdbc:postgresql://localhost:5432/recall
spring.datasource.username=recall
spring.datasource.password=recall-dev-password
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
recall.jwt.secret=your-jwt-secret-key-here-make-it-at-least-32-characters
recall.jwt.expiration-ms=86400000
What do these properties configure?
The spring.datasource lines tell Spring Boot where to find PostgreSQL and how to log in. The ddl-auto=update setting automatically creates or updates database tables based on your Java entities.
The multipart settings allow image uploads up to 10MB. The recall.jwt properties are custom values you will use later for authentication. The secret should be at least 32 characters for security.
- Save application.properties.
- Start the PostgreSQL container by opening IntelliJ's built-in terminal (View then Tool Windows then Terminal) and running:
docker compose up -d
- You should see output confirming the container started, ending with something like Container recall-postgres Started.
Container failed to start?
Make sure Docker Desktop is running. Also check that nothing else is using port 5432 on your machine.
If you see a port conflict, stop the other service or change the host port in docker-compose.yml (e.g. "5433:5432") and update application.properties to match.
Help me debug my Docker container
- Run the Spring Boot app to verify it connects to PostgreSQL. In the IntelliJ terminal, run:
./mvnw spring-boot:run
What does this command do?
The Maven wrapper (./mvnw) lets you build and run the project without installing Maven separately. The spring-boot:run goal compiles your code and starts the embedded web server.
- Watch the terminal output. After a few seconds you should see a line containing Started RecallApiApplication and the app will be listening on port 8080.
What about the Spring Security login page?
Spring Security is included in the project, so it auto-generates a login form and a random password in the console. This is expected. You will customize the security configuration in a later step.
- Press Ctrl+C in the terminal to stop the app for now.
App fails to start with a connection refused error?
This means Spring Boot cannot reach PostgreSQL. Confirm your container is running by executing docker ps in the terminal. You should see recall-postgres listed.
If the container is not listed, run docker compose up -d again and check for errors in its output.
Help me debug the connection error
✔️ Awesome, I've got everything!
Great. Double check you have saved both docker-compose.yml and application.properties.
ⓧ I'd like to double check the full code
Here is the full docker-compose.yml:
services:
postgres:
image: postgres:17
container_name: recall-postgres
environment:
POSTGRES_DB: recall
POSTGRES_USER: recall
POSTGRES_PASSWORD: recall-dev-password
ports:
- "5432:5432"
volumes:
- recall-data:/var/lib/postgresql/data
volumes:
recall-data:
Here is the full application.properties:
spring.application.name=recall-api
server.port=8080
spring.datasource.url=jdbc:postgresql://localhost:5432/recall
spring.datasource.username=recall
spring.datasource.password=recall-dev-password
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
recall.jwt.secret=your-jwt-secret-key-here-make-it-at-least-32-characters
recall.jwt.expiration-ms=86400000
Your pom.xml should contain these dependencies (generated by Spring Initializr):
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.6</version>
<relativePath/>
</parent>
<groupId>com.recall</groupId>
<artifactId>recall-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>recall-api</name>
<description>Recall - AI-powered save and act backend</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Your backend foundation is solid. You have a running Spring Boot app connected to PostgreSQL. Next up, you will set up the frontend tools and get your MAUI app ready.
Set Up Frontend Tools
Your backend is ready to accept requests. Now you need the other half of the app. The Recall frontend is a native .NET MAUI app that runs on both Mac and iOS from a single C# codebase.
In this step, you will install the .NET SDK, add the MAUI workloads, scaffold the frontend project in Rider, and grab a free Gemini API key that the backend will use for AI analysis later.
In this step, get ready to:
- Install .NET SDK 10 and the MAUI workloads.
- Create and run a .NET MAUI project in Rider.
- Get a free Gemini API key from Google AI Studio.
Install .NET SDK 10 and MAUI workloads
.NET MAUI requires the .NET SDK plus platform-specific workloads for iOS and Mac Catalyst. The SDK provides the dotnet CLI, and the workloads add the templates and build tooling for Apple platforms.
- Open your terminal and check whether the .NET SDK is already installed by running:
dotnet --version
✔️ I see version 10 or higher
Your .NET SDK is ready. Move on to installing the MAUI workloads below.
ⓧ I see an older version
You have an older .NET SDK installed. You need version 10 for this project.
- Open your browser and navigate to dotnet.microsoft.com/download/dotnet/10.0.
- Under the SDK section, find the macOS row and download the installer for your architecture (Arm64 for Apple Silicon, x64 for Intel).
- Run the downloaded .pkg installer and follow the prompts.
- Close and reopen your terminal, then run dotnet --version again. You should see a version starting with 10..
Still seeing the old version?
If you installed via Homebrew previously, the Homebrew dotnet may shadow the official installer. Run which dotnet to confirm it points to /usr/local/share/dotnet/dotnet or the /usr/local/bin/dotnet symlink. If not, remove the Homebrew version and reinstall the official .pkg.
Need more help? Why is my dotnet --version still showing the old version after installing .NET 10?
ⓧ Command not found
The .NET SDK is not installed yet. Let's install it now.
- Open your browser and navigate to dotnet.microsoft.com/download/dotnet/10.0.
- Under the SDK section, find the macOS row and download the installer for your architecture (Arm64 for Apple Silicon, x64 for Intel).
- Run the downloaded .pkg installer and follow the prompts.
- Close and reopen your terminal, then run dotnet --version to confirm it shows a version starting with 10..
Command still not found after install?
The macOS installer should configure your PATH automatically. If the command still isn't found, add the .NET install directory to your shell profile by adding this line to your ~/.zshrc:
export PATH="/usr/local/share/dotnet:$PATH"
Then run source ~/.zshrc and try dotnet --version again.
Still stuck? Help me fix my dotnet CLI not being found on macOS after installing .NET 10.
- Install the MAUI workloads by running this command:
sudo dotnet workload install maui ios maccatalyst
What does this command do?
This installs three workloads at once. maui adds the .NET MAUI project templates and build targets. ios and maccatalyst add the platform-specific SDKs your app needs to compile for iPhone and Mac.
- Wait for the installation to finish. It downloads several hundred megabytes of platform SDKs.
- The terminal will prompt you for your macOS password because it needs elevated permissions to install system-wide workloads.
Workload install failed?
Make sure you are running the command with sudo. Without it, the install will fail with a permissions error.
If you see network errors, check your internet connection and try again. The workload packages are downloaded from NuGet.
Need help? Why is my dotnet workload install failing on macOS?
Create a .NET MAUI project in Rider
With the SDK and workloads installed, you can scaffold the frontend project. Rider provides a project wizard that uses the same dotnet new maui template under the hood.
- Press Cmd+Space to open Spotlight, type Rider, and press Enter to open it.
- On the Welcome screen, click New Solution.
- In the left sidebar of the New Solution dialog, scroll down and select .NET MAUI App under the .NET / .NET MAUI section.
- Set the Solution name to Recall.
- Choose your Desktop as the location.
- Make sure the Framework is set to net10.0.
- Click Create.
What is Mac Catalyst?
Mac Catalyst is Apple's technology that lets iPad and iPhone apps run natively on macOS. Your MAUI project targets net10.0-maccatalyst and net10.0-ios simultaneously, giving you a single codebase for both platforms.
- Wait for Rider to finish restoring NuGet packages. You will see progress indicators in the bottom status bar.
- In the top toolbar, find the run configuration dropdown. Click it and select Recall | net10.0-maccatalyst as the target.
- Click the green Run button (or press Ctrl+R) to build and launch the app.
After a moment, a native Mac window should appear with the default MAUI template UI showing a "Hello, World!" label and a counter button.
Build failing or app not launching?
Make sure Xcode is installed on your Mac. MAUI needs the Apple platform SDKs that ship with Xcode for Mac Catalyst builds.
If you see errors about missing workloads, confirm they installed successfully by running:
dotnet workload list
You should see maui, ios, and maccatalyst in the output.
Still stuck? Help me fix my .NET MAUI Mac Catalyst build error in Rider.
Get a free Gemini API key
The backend will use Google's Gemini 3.5 Flash model to analyze screenshots and links. The free tier gives you enough capacity for a personal app with no credit card required.
- Open your browser and go to aistudio.google.com.
- Sign in with your Google account.
- In the left sidebar, click API Key.
- Click Create API Key.
- Select or create a Google Cloud project when prompted, then click Create API key in existing project (or Create API key in new project).
- Copy the generated API key. You will only see it once on this screen.
Why is this free?
Google offers a free tier for the Gemini API that requires no billing setup. Your input and output tokens are free of charge on the free tier. The trade-off is that free tier data may be used to improve Google products, and rate limits are lower than paid tiers.
For a personal app like Recall, the free tier is more than sufficient.
- Now store the key as an environment variable so your Spring Boot backend can read it at runtime. Open your terminal and run:
export GOOGLE_API_KEY=your-api-key-here
- Replace your-api-key-here with the key you just copied from Google AI Studio.
What does this environment variable do?
The Google GenAI Java SDK reads GOOGLE_API_KEY automatically to authenticate with the Gemini API. By storing it as an environment variable, you keep the key out of your source code.
- To make this persist across terminal sessions, add the export line to your shell profile. Open ~/.zshrc in a text editor and add the same export line at the bottom.
- Save the file and run source ~/.zshrc to reload it.
- Verify the variable is set by running:
echo $GOOGLE_API_KEY
You should see your API key printed in the terminal.
Seeing an empty output?
Make sure you ran source ~/.zshrc after editing the file. Also confirm there are no typos in the variable name. It must be exactly GOOGLE_API_KEY with no spaces around the equals sign.
Need help? Why isn't my GOOGLE_API_KEY environment variable being read?
Your frontend tools and AI credentials are all set. Next, you will start building the backend data model and REST endpoints that store and retrieve saves.
Build the Data Model and Save Endpoints
Your Spring Boot project connects to PostgreSQL, but the database is empty. Before Recall can save anything, it needs a data model that defines what a "save" looks like and REST endpoints that let clients create, retrieve, and manage saves.
In this step, you will define the Save entity that maps to a PostgreSQL table, build the repository layer for queries, and wire up the controller endpoints that the MAUI app will eventually call.
In this step, get ready to:
- Create the JPA entity that represents a save in the database.
- Build REST endpoints for creating, listing, and completing saves.
- Build a digest endpoint that groups recent saves by AI category.
Create the Save JPA entity
Every item a user saves in Recall needs a consistent shape in the database. The Save entity defines fields for the source (a URL or image), the AI-generated summary and category, and a status that tracks whether the save is active, completed, or archived.
- In IntelliJ IDEA, right-click the com.recall package in the src/main/java directory.
- Select New then Package and name it model.
- Inside the model package, create a new Java class named Save and paste the following code:
package com.recall.model;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "saves")
public class Save {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private SaveType type;
private String sourceUrl;
@Column(columnDefinition = "TEXT")
private String imageBase64;
@Column(columnDefinition = "TEXT")
private String aiSummary;
private String aiCategory;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private SaveStatus status = SaveStatus.ACTIVE;
@Column(nullable = false)
private Instant createdAt = Instant.now();
private Instant completedAt;
public enum SaveType { LINK, IMAGE }
public enum SaveStatus { ACTIVE, COMPLETED, ARCHIVED }
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public SaveType getType() { return type; }
public void setType(SaveType type) { this.type = type; }
public String getSourceUrl() { return sourceUrl; }
public void setSourceUrl(String sourceUrl) { this.sourceUrl = sourceUrl; }
public String getImageBase64() { return imageBase64; }
public void setImageBase64(String imageBase64) { this.imageBase64 = imageBase64; }
public String getAiSummary() { return aiSummary; }
public void setAiSummary(String aiSummary) { this.aiSummary = aiSummary; }
public String getAiCategory() { return aiCategory; }
public void setAiCategory(String aiCategory) { this.aiCategory = aiCategory; }
public SaveStatus getStatus() { return status; }
public void setStatus(SaveStatus status) { this.status = status; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public Instant getCompletedAt() { return completedAt; }
public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; }
}
What does this entity do?
The @Entity and @Table annotations tell Spring Data JPA to map this class to a saves table in PostgreSQL.
The SaveType enum distinguishes between link saves and image saves. The SaveStatus enum tracks the lifecycle: active means unactioned, completed means the user followed through, and archived means dismissed.
Because spring.jpa.hibernate.ddl-auto is set to update in your properties file, Hibernate will automatically create this table when the app starts.
- Save Save.java.
Seeing red squiggles on the imports?
Make sure IntelliJ has finished indexing. If jakarta.persistence is unresolved, click the Load Maven Changes icon in the top-right notification bar, or press Cmd+Shift+I (macOS) or Ctrl+Shift+O (Windows) to reimport.
Why can't IntelliJ resolve my jakarta.persistence imports?
Build the save and retrieval endpoints
With the entity in place, you need a repository to query the database and a controller to expose REST endpoints. You will also add dependencies for the Gemini SDK and JWT library that upcoming steps require.
- Add the Gemini SDK and JWT dependencies to your pom.xml by pasting the following inside the <dependencies> block, below the existing PostgreSQL dependency:
<dependency>
<groupId>com.google.genai</groupId>
<artifactId>google-genai</artifactId>
<version>1.46.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
Why add these now?
The Gemini SDK (com.google.genai) powers the AI analysis you will wire up in the next step. The jjwt libraries handle JWT token generation and validation for authentication later.
Adding them now ensures your project compiles cleanly when you create the service classes that reference these libraries.
- Click Load Maven Changes in the IntelliJ notification bar to download the new dependencies.
- Create a new package repository under com.recall.
- Inside it, create a new Java interface named SaveRepository and paste the following code:
package com.recall.repository;
import com.recall.model.Save;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
public interface SaveRepository extends JpaRepository<Save, Long> {
List<Save> findByUserIdAndStatusOrderByCreatedAtDesc(Long userId, Save.SaveStatus status);
List<Save> findByUserIdAndStatusOrderByCompletedAtDesc(Long userId, Save.SaveStatus status);
List<Save> findByUserIdAndStatusAndCreatedAtAfter(Long userId, Save.SaveStatus status, Instant since);
Optional<Save> findByIdAndUserId(Long id, Long userId);
}
What does this interface do?
Spring Data JPA automatically generates SQL queries from method names. For example, findByUserIdAndStatusOrderByCreatedAtDesc becomes a query that filters by user ID and status, then sorts by creation date descending.
You never write SQL manually. Each method signature maps to a query that the framework builds at startup.
- Save SaveRepository.java.
- Create a new package service under com.recall.
- Inside it, create a new Java class named GeminiAnalysisService with a temporary stub implementation:
package com.recall.service;
import org.springframework.stereotype.Service;
@Service
public class GeminiAnalysisService {
// Placeholder - full Gemini integration added in the next step
public AnalysisResult analyzeImage(String imageBase64, String mimeType) {
return new AnalysisResult("Saved item (AI analysis pending)", "OTHER");
}
public AnalysisResult analyzeLink(String url) {
return new AnalysisResult("Saved link (AI analysis pending)", "OTHER");
}
public record AnalysisResult(String summary, String category) {}
}
Why a stub?
The controller needs a GeminiAnalysisService to compile. This stub returns placeholder values so you can test the endpoints now. You will replace it with the real Gemini AI integration in the next step.
- Save GeminiAnalysisService.java.
- Create a new package config under com.recall.
- Inside it, create a class named SecurityConfig to temporarily disable security so you can test your endpoints:
package com.recall.config;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Configuration
public class SecurityConfig {
// Temporary config - replaced with JWT auth in a later step
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
@Component
public static class TempUserFilter implements Filter {
// Injects a hardcoded userId so endpoints work without auth
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
request.setAttribute("userId", 1L);
chain.doFilter(request, response);
}
}
}
What does this config do?
Spring Security locks down every endpoint by default. This config disables CSRF protection (not needed for a REST API using tokens) and permits all requests without authentication.
The TempUserFilter injects a hardcoded userId of 1 into every request so the controller can access @RequestAttribute Long userId without real authentication. You will replace both of these with proper JWT auth in Step 5.
- Save SecurityConfig.java.
- Now create a new package controller under com.recall.
- Inside it, create a class named SaveController and paste the following code for the core create and retrieve endpoints:
package com.recall.controller;
import com.recall.model.Save;
import com.recall.repository.SaveRepository;
import com.recall.service.GeminiAnalysisService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/saves")
public class SaveController {
private final SaveRepository saveRepository;
private final GeminiAnalysisService geminiService;
public SaveController(SaveRepository saveRepository, GeminiAnalysisService geminiService) {
this.saveRepository = saveRepository;
this.geminiService = geminiService;
}
// Save a link - sends URL to Gemini for analysis
@PostMapping("/link")
public ResponseEntity<Save> saveLink(@RequestAttribute Long userId,
@RequestBody Map<String, String> body) {
String url = body.get("url");
Save save = new Save();
save.setUserId(userId);
save.setType(Save.SaveType.LINK);
save.setSourceUrl(url);
GeminiAnalysisService.AnalysisResult analysis = geminiService.analyzeLink(url);
save.setAiSummary(analysis.summary());
save.setAiCategory(analysis.category());
return ResponseEntity.ok(saveRepository.save(save));
}
// Save an image - encodes to base64 and sends to Gemini
@PostMapping("/image")
public ResponseEntity<Save> saveImage(@RequestAttribute Long userId,
@RequestParam("file") MultipartFile file) throws Exception {
String base64 = Base64.getEncoder().encodeToString(file.getBytes());
String mimeType = file.getContentType() != null ? file.getContentType() : "image/png";
Save save = new Save();
save.setUserId(userId);
save.setType(Save.SaveType.IMAGE);
save.setImageBase64(base64);
GeminiAnalysisService.AnalysisResult analysis = geminiService.analyzeImage(base64, mimeType);
save.setAiSummary(analysis.summary());
save.setAiCategory(analysis.category());
return ResponseEntity.ok(saveRepository.save(save));
}
// List all active saves for the current user
@GetMapping
public ResponseEntity<List<Save>> listSaves(@RequestAttribute Long userId) {
List<Save> saves = saveRepository.findByUserIdAndStatusOrderByCreatedAtDesc(
userId, Save.SaveStatus.ACTIVE);
return ResponseEntity.ok(saves);
}
// Mark a save as completed
@PatchMapping("/{id}/complete")
public ResponseEntity<Save> completeSave(@RequestAttribute Long userId,
@PathVariable Long id) {
Save save = saveRepository.findByIdAndUserId(id, userId)
.orElseThrow(() -> new RuntimeException("Save not found"));
save.setStatus(Save.SaveStatus.COMPLETED);
save.setCompletedAt(Instant.now());
return ResponseEntity.ok(saveRepository.save(save));
}
// List completed saves for the archive view
@GetMapping("/completed")
public ResponseEntity<List<Save>> listCompleted(@RequestAttribute Long userId) {
List<Save> saves = saveRepository.findByUserIdAndStatusOrderByCompletedAtDesc(
userId, Save.SaveStatus.COMPLETED);
return ResponseEntity.ok(saves);
}
}
What do these endpoints do?
- POST /api/saves/link accepts a JSON body with a url field, sends it through the AI service, and persists the save.
- POST /api/saves/image accepts a multipart file upload, converts it to base64, and runs AI analysis.
- GET /api/saves returns all active (unactioned) saves for the authenticated user.
- PATCH /api/saves/{id}/complete marks a save as completed and timestamps it.
- GET /api/saves/completed returns the archive of completed saves.
- Save SaveController.java.
Compile errors in SaveController?
If GeminiAnalysisService or SaveRepository show as unresolved, make sure you saved those files first. IntelliJ needs all classes on disk before cross-references resolve.
If MultipartFile is unresolved, reimport Maven to ensure spring-boot-starter-web is indexed.
Help me debug my SaveController compile errors.
Build the digest endpoint and test the API
The daily digest is what makes Recall more than a bookmark dump. It surfaces saves from the last 24 hours, grouped by category, so the user sees a focused list of things to act on.
- In SaveController.java, find the completeSave method and add the following method directly above it (between listSaves and completeSave):
// Daily digest - recent unactioned saves grouped by AI category
@GetMapping("/digest")
public ResponseEntity<Map<String, List<Save>>> getDigest(@RequestAttribute Long userId) {
Instant since = Instant.now().minus(24, ChronoUnit.HOURS);
List<Save> recentSaves = saveRepository.findByUserIdAndStatusAndCreatedAtAfter(
userId, Save.SaveStatus.ACTIVE, since);
Map<String, List<Save>> grouped = recentSaves.stream()
.collect(Collectors.groupingBy(Save::getAiCategory));
return ResponseEntity.ok(grouped);
}
What does the digest endpoint do?
It queries saves created in the last 24 hours that are still ACTIVE. Then it groups them by the aiCategory field using Java streams. The response is a map like {"RESTAURANT": [...], "ARTICLE": [...]} that the MAUI app will render as sections in the daily digest view.
- Save SaveController.java.
- Run the application by clicking the green Run button in IntelliJ (or press Ctrl+R on macOS, Shift+F10 on Windows).
- Wait for the console to show Started RecallApiApplication on port 8080.
Your app should start without errors. Test it by creating a save.
- Open a terminal and create a test save by running:
curl -X POST http://localhost:8080/api/saves/link \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/cool-restaurant"}'
You should see a JSON response containing your save with an id, the aiSummary showing the placeholder text, and aiCategory set to OTHER.
- Verify your saves list by running:
curl http://localhost:8080/api/saves
You should see an array containing the save you just created.
Getting a 403 Forbidden or connection refused?
If you see a 403 error, your SecurityConfig may not have been picked up. Check that it is in the com.recall.config package and has the @Configuration annotation.
If you see connection refused, make sure the app started successfully and your Docker PostgreSQL container is still running (docker ps should show recall-postgres).
Help me troubleshoot my Spring Boot endpoint errors.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files and the app is running cleanly.
ⓧ I'd like to double check the full code
Here is the complete SaveController.java with all endpoints including the digest:
package com.recall.controller;
import com.recall.model.Save;
import com.recall.repository.SaveRepository;
import com.recall.service.GeminiAnalysisService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/saves")
public class SaveController {
private final SaveRepository saveRepository;
private final GeminiAnalysisService geminiService;
public SaveController(SaveRepository saveRepository, GeminiAnalysisService geminiService) {
this.saveRepository = saveRepository;
this.geminiService = geminiService;
}
@PostMapping("/link")
public ResponseEntity<Save> saveLink(@RequestAttribute Long userId,
@RequestBody Map<String, String> body) {
String url = body.get("url");
Save save = new Save();
save.setUserId(userId);
save.setType(Save.SaveType.LINK);
save.setSourceUrl(url);
GeminiAnalysisService.AnalysisResult analysis = geminiService.analyzeLink(url);
save.setAiSummary(analysis.summary());
save.setAiCategory(analysis.category());
return ResponseEntity.ok(saveRepository.save(save));
}
@PostMapping("/image")
public ResponseEntity<Save> saveImage(@RequestAttribute Long userId,
@RequestParam("file") MultipartFile file) throws Exception {
String base64 = Base64.getEncoder().encodeToString(file.getBytes());
String mimeType = file.getContentType() != null ? file.getContentType() : "image/png";
Save save = new Save();
save.setUserId(userId);
save.setType(Save.SaveType.IMAGE);
save.setImageBase64(base64);
GeminiAnalysisService.AnalysisResult analysis = geminiService.analyzeImage(base64, mimeType);
save.setAiSummary(analysis.summary());
save.setAiCategory(analysis.category());
return ResponseEntity.ok(saveRepository.save(save));
}
@GetMapping
public ResponseEntity<List<Save>> listSaves(@RequestAttribute Long userId) {
List<Save> saves = saveRepository.findByUserIdAndStatusOrderByCreatedAtDesc(
userId, Save.SaveStatus.ACTIVE);
return ResponseEntity.ok(saves);
}
@GetMapping("/digest")
public ResponseEntity<Map<String, List<Save>>> getDigest(@RequestAttribute Long userId) {
Instant since = Instant.now().minus(24, ChronoUnit.HOURS);
List<Save> recentSaves = saveRepository.findByUserIdAndStatusAndCreatedAtAfter(
userId, Save.SaveStatus.ACTIVE, since);
Map<String, List<Save>> grouped = recentSaves.stream()
.collect(Collectors.groupingBy(Save::getAiCategory));
return ResponseEntity.ok(grouped);
}
@PatchMapping("/{id}/complete")
public ResponseEntity<Save> completeSave(@RequestAttribute Long userId,
@PathVariable Long id) {
Save save = saveRepository.findByIdAndUserId(id, userId)
.orElseThrow(() -> new RuntimeException("Save not found"));
save.setStatus(Save.SaveStatus.COMPLETED);
save.setCompletedAt(Instant.now());
return ResponseEntity.ok(saveRepository.save(save));
}
@GetMapping("/completed")
public ResponseEntity<List<Save>> listCompleted(@RequestAttribute Long userId) {
List<Save> saves = saveRepository.findByUserIdAndStatusOrderByCompletedAtDesc(
userId, Save.SaveStatus.COMPLETED);
return ResponseEntity.ok(saves);
}
}
And here is the full pom.xml with all dependencies:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.6</version>
<relativePath/>
</parent>
<groupId>com.recall</groupId>
<artifactId>recall-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>recall-api</name>
<description>Recall - AI-powered save and act backend</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.google.genai</groupId>
<artifactId>google-genai</artifactId>
<version>1.46.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Your data model and REST API are live. Every endpoint works end-to-end, from receiving a request to persisting in PostgreSQL. Next up, you will replace the stub GeminiAnalysisService with a real AI integration that analyzes your saves automatically.
Add JWT Authentication
Your API endpoints accept saves, run AI analysis, and return categorized results. But right now, anyone who knows the URL can access them.
In this step, you will add JWT authentication to the backend. Each user registers with an email and password, receives a signed token, and includes that token in every request. The backend verifies the token and scopes all saves to the authenticated user.
In this step, get ready to:
- Create a User entity and JWT token service.
- Build registration and login endpoints.
- Secure all save endpoints with a JWT filter and Spring Security config.
Create the User entity and repository
Before you can authenticate anyone, you need a place to store user credentials. The User entity holds the email, hashed password, and creation timestamp for each account.
- In IntelliJ IDEA, create a new file at src/main/java/com/recall/model/User.java and paste the following code:
package com.recall.model;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Each user registers with a unique email
@Column(unique = true, nullable = false)
private String email;
// Store the BCrypt hash, never the raw password
@Column(nullable = false)
private String passwordHash;
@Column(nullable = false)
private Instant createdAt = Instant.now();
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
}
What does this code do?
The @Entity annotation maps this class to a users table in PostgreSQL. JPA auto-creates the table because ddl-auto=update is set in your application.properties.
The passwordHash field stores a BCrypt hash of the user's password. You will never store or compare raw passwords.
- Create a new file at src/main/java/com/recall/repository/UserRepository.java and paste the following code:
package com.recall.repository;
import com.recall.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
// Spring Data JPA generates the SQL queries from the method name
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
What does UserRepository do?
Spring Data JPA generates a SQL query from the method name findByEmail. When you call this method, it runs a SELECT query filtering the users table by the email column. This is all you need to look up a user during login.
- Save both files.
Seeing a compilation error?
Make sure your User.java file is in the com.recall.model package and your UserRepository.java is in com.recall.repository. IntelliJ will flag import errors if the package declaration does not match the folder path.
help me fix my User entity compilation error
Build the JWT service
The JwtService is responsible for generating tokens when a user registers or logs in, and validating tokens on every subsequent request. It reads the signing secret and expiration time from application.properties.
- Create a new file at src/main/java/com/recall/service/JwtService.java and paste the following code:
package com.recall.service;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Service
public class JwtService {
private final SecretKey key;
private final long expirationMs;
// Read the secret and expiration from application.properties
public JwtService(@Value("${recall.jwt.secret}") String secret,
@Value("${recall.jwt.expiration-ms}") long expirationMs) {
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expirationMs = expirationMs;
}
// Generate a signed JWT with the user's ID as the subject
public String generateToken(Long userId) {
return Jwts.builder()
.subject(userId.toString())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(key)
.compact();
}
// Extract the user ID from a validated token
public Long extractUserId(String token) {
String subject = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
return Long.parseLong(subject);
}
// Check whether a token is valid (not expired, not tampered)
public boolean isTokenValid(String token) {
try {
Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
return true;
} catch (Exception e) {
return false;
}
}
}
What does this code do?
The generateToken method creates a compact JWT string containing the user's ID and an expiration timestamp (24 hours by default). The server signs the token with your secret key.
The extractUserId and isTokenValid methods use the jjwt library's verifyWith method to verify the signature before reading the claims. If the token has been tampered with or expired, parsing throws an exception.
- Save JwtService.java.
You will not see a change yet. The next chunk builds the controller that uses this service.
Build registration and login endpoints
Now you will create an AuthController that exposes two public endpoints. Registration creates a new user, hashes their password, and returns a JWT. Login validates credentials and returns a JWT.
- Create a new file at src/main/java/com/recall/controller/AuthController.java and paste the following code:
package com.recall.controller;
import com.recall.model.User;
import com.recall.repository.UserRepository;
import com.recall.service.JwtService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserRepository userRepository;
private final JwtService jwtService;
private final PasswordEncoder passwordEncoder;
public AuthController(UserRepository userRepository, JwtService jwtService,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.jwtService = jwtService;
this.passwordEncoder = passwordEncoder;
}
// Register a new user and return a JWT
@PostMapping("/register")
public ResponseEntity<Map<String, String>> register(@RequestBody Map<String, String> body) {
String email = body.get("email");
String password = body.get("password");
User user = new User();
user.setEmail(email);
user.setPasswordHash(passwordEncoder.encode(password));
user = userRepository.save(user);
String token = jwtService.generateToken(user.getId());
return ResponseEntity.ok(Map.of("token", token));
}
// Validate credentials and return a JWT
@PostMapping("/login")
public ResponseEntity<Map<String, String>> login(@RequestBody Map<String, String> body) {
String email = body.get("email");
String password = body.get("password");
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found"));
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
throw new RuntimeException("Invalid password");
}
String token = jwtService.generateToken(user.getId());
return ResponseEntity.ok(Map.of("token", token));
}
}
What does this code do?
The /api/auth/register endpoint accepts an email and password, hashes the password with BCrypt, saves the user to PostgreSQL, and returns a JWT the client can use immediately.
The /api/auth/login endpoint looks up the user by email, verifies the password hash matches, and returns a fresh JWT. Both endpoints return the token in a simple JSON object.
- Save AuthController.java.
Why JWT for a personal app?
JWT tokens are stateless. The server does not need to store sessions in memory or in the database. Every request carries its own proof of identity.
This makes cross-device sync simple. Your Mac app and iOS app both send the same token with each request, and the server validates it without shared session state.
Secure endpoints with the JWT filter and Spring Security
You have auth endpoints, but the save endpoints still accept requests from anyone. You need two pieces to close this gap: a filter that reads the token from each request, and a Spring Security configuration that enforces which paths require authentication.
- Create a new file at src/main/java/com/recall/security/JwtAuthFilter.java and paste the following code:
package com.recall.security;
import com.recall.service.JwtService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
public JwtAuthFilter(JwtService jwtService) {
this.jwtService = jwtService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// Look for the Authorization header with a Bearer token
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (jwtService.isTokenValid(token)) {
// Extract userId and set it as a request attribute
Long userId = jwtService.extractUserId(token);
request.setAttribute("userId", userId);
}
}
filterChain.doFilter(request, response);
}
}
What does the filter do?
This filter runs once per request. It checks for an Authorization: Bearer <token> header, validates the token, and extracts the user ID. It then sets the user ID as a request attribute called userId.
Your SaveController already reads this attribute via @RequestAttribute Long userId. The filter is the piece that populates it.
- Save JwtAuthFilter.java.
- Create a new file at src/main/java/com/recall/config/SecurityConfig.java and paste the following code:
package com.recall.config;
import com.recall.security.JwtAuthFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
public SecurityConfig(JwtAuthFilter jwtAuthFilter) {
this.jwtAuthFilter = jwtAuthFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// Disable CSRF since we use stateless JWT auth
.csrf(csrf -> csrf.disable())
// No server-side sessions needed
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// Define which paths are public vs protected
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/saves/**").authenticated()
.anyRequest().permitAll()
)
// Run JwtAuthFilter before Spring's default auth filter
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// BCrypt password encoder used by AuthController
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
What does SecurityConfig do?
This configuration tells Spring Security three things. First, CSRF protection is off because the API uses stateless JWT tokens (no cookies). Second, sessions are disabled. Third, any request to /api/auth/** is open to everyone, while requests to /api/saves/** require an authenticated user.
The addFilterBefore line inserts your custom JwtAuthFilter before Spring's built-in authentication filter. This ensures the user ID attribute is set before Spring checks the authenticated() rule.
- Save SecurityConfig.java.
App won't start after adding SecurityConfig?
Make sure your SecurityConfig.java is in the com.recall.config package and JwtAuthFilter.java is in the com.recall.security package. Both must be within the base com.recall package for Spring's component scanning to find them.
help me fix my Spring Security config
Test the full authentication flow
Time to verify everything works end-to-end. You will register a user, log in, create a save with the token, and fetch the digest.
- Make sure your Spring Boot app is running. In the IntelliJ terminal, run:
./mvnw spring-boot:run
- Register a test user by running this curl command in a separate terminal:
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "test@recall.app", "password": "mypassword123"}'
You should see a JSON response containing a token field with a long encoded string.
- Copy the token value from the response. Test creating a save by running this command (replace your-token-here with the actual token you received):
curl -X POST http://localhost:8080/api/saves/link \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token-here" \
-d '{"url": "https://www.seriouseats.com/best-ramen-tokyo"}'
You should see a JSON response with the save object, including aiSummary and aiCategory fields populated by Gemini.
- Verify the digest endpoint works by running:
curl http://localhost:8080/api/saves/digest \
-H "Authorization: Bearer your-token-here"
You should see your save grouped by its AI category.
- Verify that unauthenticated requests are rejected by running:
curl http://localhost:8080/api/saves
You should receive a 401 or 403 response, confirming that the endpoint is now protected.
Getting a 403 on authenticated requests?
Double-check that you are passing the Authorization header with the prefix Bearer (note the space after Bearer). The filter checks for this exact prefix.
Also verify that your recall.jwt.secret in application.properties is at least 32 characters long. The HMAC-SHA algorithm requires a key of this minimum length.
help me debug my JWT authentication
✔️ Awesome, I've got everything!
Great. Double check you have saved all your new files before moving on.
ⓧ I'd like to double check the full code
Here is the full JwtService.java for reference:
package com.recall.service;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Service
public class JwtService {
private final SecretKey key;
private final long expirationMs;
public JwtService(@Value("${recall.jwt.secret}") String secret,
@Value("${recall.jwt.expiration-ms}") long expirationMs) {
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expirationMs = expirationMs;
}
public String generateToken(Long userId) {
return Jwts.builder()
.subject(userId.toString())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(key)
.compact();
}
public Long extractUserId(String token) {
String subject = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
return Long.parseLong(subject);
}
public boolean isTokenValid(String token) {
try {
Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
return true;
} catch (Exception e) {
return false;
}
}
}
Here is the full SecurityConfig.java for reference:
package com.recall.config;
import com.recall.security.JwtAuthFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
public SecurityConfig(JwtAuthFilter jwtAuthFilter) {
this.jwtAuthFilter = jwtAuthFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/saves/**").authenticated()
.anyRequest().permitAll()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Here is the full AuthController.java for reference:
package com.recall.controller;
import com.recall.model.User;
import com.recall.repository.UserRepository;
import com.recall.service.JwtService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserRepository userRepository;
private final JwtService jwtService;
private final PasswordEncoder passwordEncoder;
public AuthController(UserRepository userRepository, JwtService jwtService,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.jwtService = jwtService;
this.passwordEncoder = passwordEncoder;
}
@PostMapping("/register")
public ResponseEntity<Map<String, String>> register(@RequestBody Map<String, String> body) {
String email = body.get("email");
String password = body.get("password");
User user = new User();
user.setEmail(email);
user.setPasswordHash(passwordEncoder.encode(password));
user = userRepository.save(user);
String token = jwtService.generateToken(user.getId());
return ResponseEntity.ok(Map.of("token", token));
}
@PostMapping("/login")
public ResponseEntity<Map<String, String>> login(@RequestBody Map<String, String> body) {
String email = body.get("email");
String password = body.get("password");
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found"));
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
throw new RuntimeException("Invalid password");
}
String token = jwtService.generateToken(user.getId());
return ResponseEntity.ok(Map.of("token", token));
}
}
Your API is now fully secured. Every save is scoped to the user who created it. Next up, you will build the .NET MAUI frontend that talks to these endpoints.
Build the MAUI Core UI and Save List
Your backend is live and handling saves, authentication, and AI analysis. Now it's time to build the front half of Recall.
In this step, you'll create the .NET MAUI UI that displays saves grouped by AI category, lets you view details, and connects to your Spring Boot backend through an API service layer with JWT authentication.
In this step, get ready to:
- Build the main page with a grouped CollectionView showing saves by AI category.
- Create the save detail page with a swipe-to-complete gesture.
- Wire up an API service class with login and register screens.
Design the main page with a grouped CollectionView
The main screen of Recall shows all your active saves organized by AI category. Each category (Restaurants, Articles, Products) becomes a section header, with individual saves displayed as rounded cards underneath.
- In Rider, open your Recall MAUI project.
- Create a new folder called Models inside the project root.
- Create a new file called SaveItem.cs inside the Models folder and add this model class:
using System.Collections.ObjectModel;
namespace Recall.Models;
// Represents a single save from the backend
public class SaveItem
{
public long Id { get; set; }
public string Type { get; set; } = string.Empty;
public string? SourceUrl { get; set; }
public string? ImageBase64 { get; set; }
public string? AiSummary { get; set; }
public string? AiCategory { get; set; }
public string Status { get; set; } = "ACTIVE";
public DateTime CreatedAt { get; set; }
public DateTime? CompletedAt { get; set; }
}
// Groups saves by AI category for the CollectionView
public class SaveGroup : ObservableCollection<SaveItem>
{
public string CategoryName { get; set; }
public SaveGroup(string categoryName, IEnumerable<SaveItem> items)
: base(items)
{
CategoryName = categoryName;
}
}
What does this code do?
SaveItem mirrors the fields from your backend's Save entity. The app deserializes JSON responses into these objects.
SaveGroup extends ObservableCollection<SaveItem> so the MAUI CollectionView can group items by their AI-assigned category. Each group becomes a section header in the list.
- Save SaveItem.cs.
- Now replace the contents of MainPage.xaml with the following XAML:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Recall.MainPage"
Title="Recall"
BackgroundColor="White">
<!-- Main page showing saves grouped by AI category -->
<RefreshView x:Name="RefreshContainer"
IsRefreshing="{Binding IsRefreshing}"
Command="{Binding RefreshCommand}">
<CollectionView x:Name="SavesCollection"
IsGrouped="True"
SelectionMode="Single"
SelectionChanged="OnSaveSelected">
<!-- Group header displays the AI category name -->
<CollectionView.GroupHeaderTemplate>
<DataTemplate>
<Label Text="{Binding CategoryName}"
FontSize="14"
FontAttributes="Bold"
TextColor="Gray"
Padding="16,12,16,4" />
</DataTemplate>
</CollectionView.GroupHeaderTemplate>
<!-- Each save rendered as a rounded card -->
<CollectionView.ItemTemplate>
<DataTemplate>
<SwipeView>
<!-- Swipe left to reveal Complete action -->
<SwipeView.RightItems>
<SwipeItems Mode="Execute">
<SwipeItem Text="Complete"
BackgroundColor="#4CAF50"
Invoked="OnCompleteSwipeInvoked" />
</SwipeItems>
</SwipeView.RightItems>
<!-- Card content -->
<Frame Margin="12,6"
Padding="16"
CornerRadius="12"
BorderColor="#E0E0E0"
HasShadow="False"
BackgroundColor="#FAFAFA">
<VerticalStackLayout Spacing="4">
<!-- AI summary as the headline -->
<Label Text="{Binding AiSummary}"
FontSize="16"
FontAttributes="Bold"
LineBreakMode="TailTruncation"
MaxLines="2" />
<!-- Source type and date -->
<Label FontSize="12"
TextColor="Gray">
<Label.FormattedText>
<FormattedString>
<Span Text="{Binding Type}" />
<Span Text=" · " />
<Span Text="{Binding CreatedAt, StringFormat='{0:MMM dd}'}" />
</FormattedString>
</Label.FormattedText>
</Label>
</VerticalStackLayout>
</Frame>
</SwipeView>
</DataTemplate>
</CollectionView.ItemTemplate>
<!-- Empty state when no saves exist -->
<CollectionView.EmptyView>
<VerticalStackLayout HorizontalOptions="Center"
VerticalOptions="Center"
Padding="40">
<Label Text="No saves yet"
FontSize="18"
HorizontalOptions="Center"
TextColor="Gray" />
<Label Text="Save a link or screenshot to get started."
FontSize="14"
HorizontalOptions="Center"
TextColor="LightGray" />
</VerticalStackLayout>
</CollectionView.EmptyView>
</CollectionView>
</RefreshView>
</ContentPage>
What's happening here?
- IsGrouped="True" tells the CollectionView to expect a collection of groups. Each group renders its own header.
- The SwipeView wraps each item card. Swiping left reveals a green "Complete" button that marks the save as done.
- The Frame creates rounded cards with subtle borders. The AI summary is the headline, with the save type and date below.
- The EmptyView shows a friendly message when there are no saves to display.
- Now replace the code-behind in MainPage.xaml.cs with:
using Recall.Models;
using Recall.Services;
namespace Recall;
public partial class MainPage : ContentPage
{
private readonly ApiService _apiService;
public MainPage()
{
InitializeComponent();
_apiService = new ApiService();
}
protected override async void OnAppearing()
{
base.OnAppearing();
await LoadSaves();
}
// Fetches saves from the backend and groups them by category
private async Task LoadSaves()
{
try
{
var saves = await _apiService.GetSavesAsync();
var grouped = saves
.GroupBy(s => s.AiCategory ?? "OTHER")
.Select(g => new SaveGroup(g.Key, g))
.ToList();
SavesCollection.ItemsSource = grouped;
}
catch (Exception ex)
{
await DisplayAlert("Error", "Could not load saves: " + ex.Message, "OK");
}
}
// Navigate to detail page when a save is tapped
private async void OnSaveSelected(object sender, SelectionChangedEventArgs e)
{
if (e.CurrentSelection.FirstOrDefault() is SaveItem selected)
{
await Navigation.PushAsync(new SaveDetailPage(selected, _apiService));
((CollectionView)sender).SelectedItem = null;
}
}
// Handles the swipe-to-complete gesture
private async void OnCompleteSwipeInvoked(object sender, EventArgs e)
{
if (sender is SwipeItem swipeItem &&
swipeItem.BindingContext is SaveItem save)
{
await _apiService.CompleteSaveAsync(save.Id);
await LoadSaves();
}
}
}
- Save both MainPage.xaml and MainPage.xaml.cs.
Seeing red squiggles under ApiService?
That's expected. You haven't created the Services/ApiService.cs file yet. You'll add it in the third substep. The project won't compile until then.
help me fix build errors in my MAUI project
Build the save detail page
When a learner taps on a save in the list, they need to see the full picture. The detail page shows the AI summary, the category, the source (link or image thumbnail), the creation date, and a prominent Complete button.
- Create a new file called SaveDetailPage.xaml in the project root and add this XAML:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Recall.SaveDetailPage"
Title="Save Details"
BackgroundColor="White">
<ScrollView Padding="20">
<VerticalStackLayout Spacing="16">
<!-- AI category badge -->
<Frame Padding="8,4"
CornerRadius="8"
BackgroundColor="#E8F5E9"
BorderColor="Transparent"
HasShadow="False"
HorizontalOptions="Start">
<Label x:Name="CategoryLabel"
FontSize="12"
FontAttributes="Bold"
TextColor="#2E7D32" />
</Frame>
<!-- Full AI summary -->
<Label x:Name="SummaryLabel"
FontSize="20"
FontAttributes="Bold"
LineBreakMode="WordWrap" />
<!-- Source info (link or image thumbnail) -->
<Frame Padding="12"
CornerRadius="8"
BackgroundColor="#F5F5F5"
BorderColor="#E0E0E0"
HasShadow="False">
<VerticalStackLayout Spacing="8">
<Label Text="Source"
FontSize="12"
TextColor="Gray" />
<Label x:Name="SourceLabel"
FontSize="14"
TextColor="#1976D2"
LineBreakMode="TailTruncation" />
<Image x:Name="SourceImage"
MaximumHeightRequest="200"
Aspect="AspectFit"
IsVisible="False" />
</VerticalStackLayout>
</Frame>
<!-- Creation date -->
<Label x:Name="DateLabel"
FontSize="14"
TextColor="Gray" />
<!-- Prominent Complete button -->
<Button x:Name="CompleteButton"
Text="Mark as Complete"
BackgroundColor="#4CAF50"
TextColor="White"
FontAttributes="Bold"
CornerRadius="12"
HeightRequest="50"
Margin="0,20,0,0"
Clicked="OnCompleteClicked" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>
- Create the code-behind file SaveDetailPage.xaml.cs with:
using Recall.Models;
using Recall.Services;
namespace Recall;
public partial class SaveDetailPage : ContentPage
{
private readonly SaveItem _save;
private readonly ApiService _apiService;
public SaveDetailPage(SaveItem save, ApiService apiService)
{
InitializeComponent();
_save = save;
_apiService = apiService;
PopulateDetails();
}
// Fills the UI with save data
private void PopulateDetails()
{
CategoryLabel.Text = _save.AiCategory ?? "OTHER";
SummaryLabel.Text = _save.AiSummary ?? "No summary available";
DateLabel.Text = $"Saved on {_save.CreatedAt:MMMM dd, yyyy}";
// Show link or image depending on save type
if (_save.Type == "LINK" && !string.IsNullOrEmpty(_save.SourceUrl))
{
SourceLabel.Text = _save.SourceUrl;
SourceLabel.IsVisible = true;
SourceImage.IsVisible = false;
}
else if (_save.Type == "IMAGE" && !string.IsNullOrEmpty(_save.ImageBase64))
{
SourceLabel.IsVisible = false;
SourceImage.IsVisible = true;
SourceImage.Source = ImageSource.FromStream(() =>
new MemoryStream(Convert.FromBase64String(_save.ImageBase64)));
}
// Hide complete button if already completed
if (_save.Status == "COMPLETED")
{
CompleteButton.IsVisible = false;
}
}
// Marks the save as complete and navigates back
private async void OnCompleteClicked(object sender, EventArgs e)
{
await _apiService.CompleteSaveAsync(_save.Id);
await DisplayAlert("Done", "Save marked as complete.", "OK");
await Navigation.PopAsync();
}
}
What does this code do?
- PopulateDetails() fills the page with data from the tapped save. It shows the link URL for link-type saves, or renders the base64-encoded image for image-type saves.
- The Complete button calls CompleteSaveAsync on the API service, which hits PATCH /api/saves/{id}/complete on your backend.
- If the save is already completed, the button hides itself so users can't double-complete.
- Save both SaveDetailPage.xaml and SaveDetailPage.xaml.cs.
Create the API service and wire login screens
The API service is the bridge between your MAUI app and the Spring Boot backend. It handles JWT token storage, makes authenticated HTTP calls, and caches saves locally for offline viewing.
- Create a new folder called Services in the project root.
- Create a new file called ApiService.cs inside the Services folder and add this code:
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Recall.Models;
namespace Recall.Services;
public class ApiService
{
private readonly HttpClient _client;
private readonly JsonSerializerOptions _jsonOptions;
// Base URL pointing to the local Spring Boot backend
private const string BaseUrl = "http://localhost:8080";
// Local cache for offline viewing
private List<SaveItem>? _cachedSaves;
public ApiService()
{
_client = new HttpClient();
_client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}
// Stores the JWT securely on device
private async Task SetTokenAsync(string token)
{
await SecureStorage.Default.SetAsync("jwt_token", token);
_client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
}
// Retrieves the stored JWT and attaches it to requests
public async Task<bool> LoadTokenAsync()
{
var token = await SecureStorage.Default.GetAsync("jwt_token");
if (token == null) return false;
_client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
return true;
}
// Registers a new user and stores the returned JWT
public async Task<bool> RegisterAsync(string email, string password)
{
var response = await _client.PostAsJsonAsync(
$"{BaseUrl}/api/auth/register",
new { email, password });
if (!response.IsSuccessStatusCode) return false;
var result = await response.Content.ReadFromJsonAsync<AuthResponse>(_jsonOptions);
if (result?.Token != null)
{
await SetTokenAsync(result.Token);
return true;
}
return false;
}
// Logs in and stores the returned JWT
public async Task<bool> LoginAsync(string email, string password)
{
var response = await _client.PostAsJsonAsync(
$"{BaseUrl}/api/auth/login",
new { email, password });
if (!response.IsSuccessStatusCode) return false;
var result = await response.Content.ReadFromJsonAsync<AuthResponse>(_jsonOptions);
if (result?.Token != null)
{
await SetTokenAsync(result.Token);
return true;
}
return false;
}
// Fetches active saves and caches them locally
public async Task<List<SaveItem>> GetSavesAsync()
{
try
{
var saves = await _client.GetFromJsonAsync<List<SaveItem>>(
$"{BaseUrl}/api/saves", _jsonOptions);
if (saves != null)
{
_cachedSaves = saves;
}
return _cachedSaves ?? new List<SaveItem>();
}
catch (HttpRequestException)
{
// Return cached saves when offline
return _cachedSaves ?? new List<SaveItem>();
}
}
// Marks a save as completed
public async Task CompleteSaveAsync(long saveId)
{
await _client.PatchAsync(
$"{BaseUrl}/api/saves/{saveId}/complete", null);
}
// Fetches the daily digest grouped by category
public async Task<Dictionary<string, List<SaveItem>>> GetDigestAsync()
{
try
{
var digest = await _client.GetFromJsonAsync<Dictionary<string, List<SaveItem>>>(
$"{BaseUrl}/api/saves/digest", _jsonOptions);
return digest ?? new Dictionary<string, List<SaveItem>>();
}
catch (HttpRequestException)
{
return new Dictionary<string, List<SaveItem>>();
}
}
// Clears stored credentials on logout
public void Logout()
{
SecureStorage.Default.Remove("jwt_token");
_client.DefaultRequestHeaders.Authorization = null;
_cachedSaves = null;
}
}
// Response shape from auth endpoints
public class AuthResponse
{
public string? Token { get; set; }
}
What does this code do?
- SecureStorage.Default.SetAsync stores the JWT encrypted on-device using the iOS Keychain or platform-equivalent. This persists the login across app restarts.
- LoadTokenAsync() checks for an existing token on app start. If found, the user skips the login screen.
- GetSavesAsync() caches the response in _cachedSaves. If the network request fails, it returns the cached list so the app still works offline.
- The base URL http://localhost:8080 points to your local Spring Boot backend. Both Mac Catalyst and the iOS Simulator can reach localhost directly.
- Save ApiService.cs.
- Now create LoginPage.xaml in the project root:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Recall.LoginPage"
Title="Sign In"
BackgroundColor="White">
<VerticalStackLayout Padding="32"
Spacing="16"
VerticalOptions="Center">
<!-- App title -->
<Label Text="Recall"
FontSize="32"
FontAttributes="Bold"
HorizontalOptions="Center"
Margin="0,0,0,20" />
<!-- Email field -->
<Entry x:Name="EmailEntry"
Placeholder="Email"
Keyboard="Email" />
<!-- Password field -->
<Entry x:Name="PasswordEntry"
Placeholder="Password"
IsPassword="True" />
<!-- Sign in button -->
<Button Text="Sign In"
BackgroundColor="#1976D2"
TextColor="White"
FontAttributes="Bold"
CornerRadius="8"
Clicked="OnLoginClicked" />
<!-- Error message -->
<Label x:Name="ErrorLabel"
TextColor="Red"
IsVisible="False"
HorizontalOptions="Center" />
<!-- Navigate to register -->
<Label Text="Don't have an account? Register"
TextColor="#1976D2"
HorizontalOptions="Center">
<Label.GestureRecognizers>
<TapGestureRecognizer Tapped="OnRegisterTapped" />
</Label.GestureRecognizers>
</Label>
</VerticalStackLayout>
</ContentPage>
- Create the code-behind LoginPage.xaml.cs:
using Recall.Services;
namespace Recall;
public partial class LoginPage : ContentPage
{
private readonly ApiService _apiService;
public LoginPage()
{
InitializeComponent();
_apiService = new ApiService();
}
// Attempts login and navigates to main page on success
private async void OnLoginClicked(object sender, EventArgs e)
{
var email = EmailEntry.Text?.Trim();
var password = PasswordEntry.Text;
if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(password))
{
ErrorLabel.Text = "Please enter both email and password.";
ErrorLabel.IsVisible = true;
return;
}
var success = await _apiService.LoginAsync(email, password);
if (success)
{
Application.Current!.MainPage = new NavigationPage(new MainPage());
}
else
{
ErrorLabel.Text = "Invalid email or password.";
ErrorLabel.IsVisible = true;
}
}
// Navigates to the register page
private async void OnRegisterTapped(object sender, EventArgs e)
{
await Navigation.PushAsync(new RegisterPage());
}
}
- Create RegisterPage.xaml in the project root:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Recall.RegisterPage"
Title="Create Account"
BackgroundColor="White">
<VerticalStackLayout Padding="32"
Spacing="16"
VerticalOptions="Center">
<!-- Page heading -->
<Label Text="Create Account"
FontSize="28"
FontAttributes="Bold"
HorizontalOptions="Center"
Margin="0,0,0,20" />
<!-- Email field -->
<Entry x:Name="EmailEntry"
Placeholder="Email"
Keyboard="Email" />
<!-- Password field -->
<Entry x:Name="PasswordEntry"
Placeholder="Password"
IsPassword="True" />
<!-- Register button -->
<Button Text="Create Account"
BackgroundColor="#1976D2"
TextColor="White"
FontAttributes="Bold"
CornerRadius="8"
Clicked="OnRegisterClicked" />
<!-- Error message -->
<Label x:Name="ErrorLabel"
TextColor="Red"
IsVisible="False"
HorizontalOptions="Center" />
</VerticalStackLayout>
</ContentPage>
- Create the code-behind RegisterPage.xaml.cs:
using Recall.Services;
namespace Recall;
public partial class RegisterPage : ContentPage
{
private readonly ApiService _apiService;
public RegisterPage()
{
InitializeComponent();
_apiService = new ApiService();
}
// Registers a new account and navigates to main page on success
private async void OnRegisterClicked(object sender, EventArgs e)
{
var email = EmailEntry.Text?.Trim();
var password = PasswordEntry.Text;
if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(password))
{
ErrorLabel.Text = "Please fill in all fields.";
ErrorLabel.IsVisible = true;
return;
}
var success = await _apiService.RegisterAsync(email, password);
if (success)
{
Application.Current!.MainPage = new NavigationPage(new MainPage());
}
else
{
ErrorLabel.Text = "Registration failed. Email may already exist.";
ErrorLabel.IsVisible = true;
}
}
}
How does navigation work?
On successful login or registration, the app swaps the entire MainPage of the application to a NavigationPage wrapping your saves list. This gives you a navigation stack for pushing the detail page.
The login page is the entry point. If a stored JWT exists from a previous session, the app can skip login entirely using LoadTokenAsync().
- Finally, update App.xaml.cs to set the login page as the starting page and check for existing tokens:
using Recall.Services;
namespace Recall;
public partial class App : Application
{
public App()
{
InitializeComponent();
}
// Sets the initial page based on whether a JWT token exists
protected override async void OnStart()
{
base.OnStart();
var apiService = new ApiService();
var hasToken = await apiService.LoadTokenAsync();
if (hasToken)
{
MainPage = new NavigationPage(new MainPage());
}
else
{
MainPage = new NavigationPage(new LoginPage());
}
}
}
- Save all files.
- In Rider, select the Mac Catalyst target from the run configuration dropdown.
- Click Run (or press Ctrl+R) to build and launch the app.
- You should see the login screen with the "Recall" title, email and password fields, and a Sign In button.
App won't build?
- Make sure you created the Models folder with SaveItem.cs and the Services folder with ApiService.cs. Missing files cause namespace errors.
- Check that all namespaces match. Every file should use namespace Recall, namespace Recall.Models, or namespace Recall.Services.
- If you see a Mac Catalyst entitlements error, make sure you selected the correct target in the run configuration.
help me debug my MAUI build errors
Your MAUI app now has a full UI layer with grouped saves, a detail view, swipe-to-complete, and authenticated API calls. Next up, you'll add the flows for creating new saves through link pasting and screenshot uploads.
Add Save Creation Flows and Share Extension
Your app can display and complete saves, but there is no way to create them from the app itself yet.
In this step, you will build two in-app flows for creating saves (paste a link and upload a screenshot) plus an iOS/Mac Share Extension that lets users send content from any app directly to Recall.
In this step, get ready to:
- Build the paste-link flow that sends a URL to the backend and displays AI analysis.
- Build the screenshot upload flow using MAUI's MediaPicker.
- Implement an iOS/Mac Share Extension with App Group entitlements.
Build the paste-link flow
Users need a quick way to save a URL they have copied. The paste-link page detects clipboard content and sends the URL to the backend, which runs Gemini AI analysis and returns a category and summary.
- In Rider, right-click the Recall project in the Solution Explorer.
- Select Add then New File.
- Choose .NET MAUI ContentPage (XAML) and name it AddLinkPage.xaml.
- Replace the generated content with the following XAML:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Recall.AddLinkPage"
Title="Save a Link">
<VerticalStackLayout Padding="20" Spacing="16">
<!-- URL input field -->
<Entry x:Name="UrlEntry"
Placeholder="Paste a URL here"
Keyboard="Url" />
<!-- Paste from clipboard button -->
<Button Text="Paste from Clipboard"
Clicked="OnPasteClicked" />
<!-- Submit button -->
<Button x:Name="SaveButton"
Text="Save Link"
Clicked="OnSaveClicked"
IsEnabled="False" />
<!-- Loading indicator -->
<ActivityIndicator x:Name="LoadingIndicator"
IsRunning="False"
IsVisible="False" />
<!-- AI analysis result display -->
<Frame x:Name="ResultFrame"
IsVisible="False"
Padding="12"
CornerRadius="8"
BackgroundColor="{AppThemeBinding Light=#F5F5F5, Dark=#2A2A2A}">
<VerticalStackLayout Spacing="8">
<Label x:Name="CategoryLabel"
FontAttributes="Bold"
FontSize="16" />
<Label x:Name="SummaryLabel"
FontSize="14" />
</VerticalStackLayout>
</Frame>
</VerticalStackLayout>
</ContentPage>
- Now open the code-behind file AddLinkPage.xaml.cs and replace its content with this:
using Recall.Services;
namespace Recall;
public partial class AddLinkPage : ContentPage
{
private readonly ApiService _apiService;
public AddLinkPage(ApiService apiService)
{
InitializeComponent();
_apiService = apiService;
// Enable save button when text changes
UrlEntry.TextChanged += (s, e) =>
SaveButton.IsEnabled = !string.IsNullOrWhiteSpace(e.NewTextValue);
}
// Detect clipboard content and paste into the entry
private async void OnPasteClicked(object sender, EventArgs e)
{
if (Clipboard.Default.HasText)
{
var text = await Clipboard.Default.GetTextAsync();
if (!string.IsNullOrWhiteSpace(text))
{
UrlEntry.Text = text;
}
}
}
// Send the URL to the backend for AI analysis
private async void OnSaveClicked(object sender, EventArgs e)
{
var url = UrlEntry.Text?.Trim();
if (string.IsNullOrWhiteSpace(url)) return;
LoadingIndicator.IsRunning = true;
LoadingIndicator.IsVisible = true;
SaveButton.IsEnabled = false;
ResultFrame.IsVisible = false;
try
{
// Call the backend save link endpoint
var save = await _apiService.SaveLinkAsync(url);
// Display the AI analysis result
CategoryLabel.Text = save.AiCategory;
SummaryLabel.Text = save.AiSummary;
ResultFrame.IsVisible = true;
}
catch (Exception ex)
{
await DisplayAlert("Error", $"Failed to save link: {ex.Message}", "OK");
}
finally
{
LoadingIndicator.IsRunning = false;
LoadingIndicator.IsVisible = false;
SaveButton.IsEnabled = true;
}
}
}
What does this code do?
The XAML defines a simple page with a URL text field, a paste button, and a save button. When the user taps Paste from Clipboard, it reads the clipboard and fills the entry field.
When Save Link is tapped, the code-behind calls SaveLinkAsync on the API service, which posts to POST /api/saves/link. The returned AI category and summary are displayed in a card below.
- Now add the SaveLinkAsync method to ApiService.cs. Switch back to Recall/Services/ApiService.cs and add this method:
// Send a URL to the backend for AI analysis and save
public async Task<SaveItem> SaveLinkAsync(string url)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/api/saves/link");
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _token);
request.Content = new StringContent(
System.Text.Json.JsonSerializer.Serialize(new { url }),
System.Text.Encoding.UTF8,
"application/json");
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return System.Text.Json.JsonSerializer.Deserialize<SaveItem>(json)!;
}
What does SaveLinkAsync do?
It sends a POST request to /api/saves/link with the URL in a JSON body. The backend runs Gemini analysis on the link, stores the save, and returns the full save object including aiSummary and aiCategory.
- Save both AddLinkPage.xaml.cs and ApiService.cs.
- Build the project in Rider to verify there are no compilation errors.
Build errors in AddLinkPage?
Make sure your AddLinkPage constructor accepts ApiService as a parameter and that ApiService is registered in your dependency injection container.
Check that the SaveItem model class has AiCategory and AiSummary properties matching the JSON response from your backend.
Help me debug my AddLinkPage build errors
Build the upload screenshot flow
MAUI's MediaPicker API lets you open the device camera or photo gallery from a single cross-platform interface. The picker returns a FileResult you can read as a stream and convert to base64 for upload.
- First, add camera and photo library permissions to the iOS Info.plist. Open Recall/Platforms/iOS/Info.plist and add these keys inside the top-level <dict>:
<key>NSCameraUsageDescription</key>
<string>Recall needs camera access to capture screenshots for saving.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Recall needs photo library access to select images for saving.</string>
Why are these permissions needed?
iOS requires apps to declare why they need access to the camera and photo library. Without these keys, the app will crash when MediaPicker tries to open the camera or gallery.
- Create a new file AddImagePage.xaml in the Recall project using the same method as before (right-click, Add, New File, .NET MAUI ContentPage (XAML)).
- Add the following XAML:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Recall.AddImagePage"
Title="Save a Screenshot">
<VerticalStackLayout Padding="20" Spacing="16">
<!-- Image preview -->
<Image x:Name="PreviewImage"
HeightRequest="200"
Aspect="AspectFit"
IsVisible="False" />
<!-- Action buttons -->
<Button Text="Pick from Gallery"
Clicked="OnPickClicked" />
<Button Text="Take Photo"
Clicked="OnCaptureClicked" />
<!-- Upload button -->
<Button x:Name="UploadButton"
Text="Upload and Analyze"
Clicked="OnUploadClicked"
IsEnabled="False" />
<!-- Loading indicator -->
<ActivityIndicator x:Name="LoadingIndicator"
IsRunning="False"
IsVisible="False" />
<!-- AI analysis result -->
<Frame x:Name="ResultFrame"
IsVisible="False"
Padding="12"
CornerRadius="8"
BackgroundColor="{AppThemeBinding Light=#F5F5F5, Dark=#2A2A2A}">
<VerticalStackLayout Spacing="8">
<Label x:Name="CategoryLabel"
FontAttributes="Bold"
FontSize="16" />
<Label x:Name="SummaryLabel"
FontSize="14" />
</VerticalStackLayout>
</Frame>
</VerticalStackLayout>
</ContentPage>
- Open AddImagePage.xaml.cs and replace its content with this code-behind:
using Recall.Services;
namespace Recall;
public partial class AddImagePage : ContentPage
{
private readonly ApiService _apiService;
private FileResult? _selectedPhoto;
public AddImagePage(ApiService apiService)
{
InitializeComponent();
_apiService = apiService;
}
// Open the gallery to pick an existing photo
private async void OnPickClicked(object sender, EventArgs e)
{
var photo = await MediaPicker.Default.PickPhotoAsync();
if (photo != null)
{
_selectedPhoto = photo;
PreviewImage.Source = ImageSource.FromFile(photo.FullPath);
PreviewImage.IsVisible = true;
UploadButton.IsEnabled = true;
}
}
// Open the camera to capture a new photo
private async void OnCaptureClicked(object sender, EventArgs e)
{
if (!MediaPicker.Default.IsCaptureSupported)
{
await DisplayAlert("Not Supported", "Camera capture is not available on this device.", "OK");
return;
}
var photo = await MediaPicker.Default.CapturePhotoAsync();
if (photo != null)
{
_selectedPhoto = photo;
PreviewImage.Source = ImageSource.FromFile(photo.FullPath);
PreviewImage.IsVisible = true;
UploadButton.IsEnabled = true;
}
}
// Upload the selected photo to the backend
private async void OnUploadClicked(object sender, EventArgs e)
{
if (_selectedPhoto == null) return;
LoadingIndicator.IsRunning = true;
LoadingIndicator.IsVisible = true;
UploadButton.IsEnabled = false;
ResultFrame.IsVisible = false;
try
{
// Read the file as a stream
using var stream = await _selectedPhoto.OpenReadAsync();
var save = await _apiService.SaveImageAsync(stream, _selectedPhoto.FileName);
// Display the AI categorization
CategoryLabel.Text = save.AiCategory;
SummaryLabel.Text = save.AiSummary;
ResultFrame.IsVisible = true;
}
catch (Exception ex)
{
await DisplayAlert("Error", $"Failed to upload image: {ex.Message}", "OK");
}
finally
{
LoadingIndicator.IsRunning = false;
LoadingIndicator.IsVisible = false;
UploadButton.IsEnabled = true;
}
}
}
What does this code do?
The page offers two buttons: one opens the photo gallery (PickPhotoAsync) and the other opens the camera (CapturePhotoAsync). Both return a FileResult that holds the image path and a stream.
When the user taps Upload and Analyze, the code reads the stream and calls SaveImageAsync which posts the file to POST /api/saves/image. The backend analyzes the image with Gemini and returns the AI category and summary.
- Add the SaveImageAsync method to ApiService.cs:
// Upload an image to the backend for AI analysis
public async Task<SaveItem> SaveImageAsync(Stream imageStream, string fileName)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/api/saves/image");
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _token);
// Build multipart form data with the image file
var content = new MultipartFormDataContent();
var streamContent = new StreamContent(imageStream);
streamContent.Headers.ContentType =
new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
content.Add(streamContent, "file", fileName);
request.Content = content;
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return System.Text.Json.JsonSerializer.Deserialize<SaveItem>(json)!;
}
What does SaveImageAsync do?
It wraps the image stream in a MultipartFormDataContent with the field name file. This matches the @RequestParam("file") MultipartFile on the Spring Boot backend. The backend converts the image to base64, sends it to Gemini, and returns the categorized save.
- Save AddImagePage.xaml, AddImagePage.xaml.cs, and ApiService.cs.
- Build and run the app on the Mac Catalyst target. Navigate to the Add Image page, pick a photo from your gallery, and tap Upload and Analyze. You should see the AI category and summary appear in the result card.
Camera not working on the simulator?
Camera capture only works on physical iOS devices. On the Mac Catalyst target and iOS Simulator, use Pick from Gallery instead. The IsCaptureSupported check in the code handles this gracefully.
If you see a crash when picking a photo, make sure you added NSPhotoLibraryUsageDescription to your Info.plist.
Help me debug my MediaPicker image upload flow
Implement the iOS/Mac Share Extension
A Share Extension lets users send content from any app (Safari, Photos, Notes) directly to Recall without opening it first. The extension stores the shared data in a shared App Group container, then launches the main app via a custom URL scheme to process it.
Why use an App Group?
iOS sandboxes each app and its extensions separately. The only way for a Share Extension to pass data to the main app is through a shared container. An App Group entitlement gives both the extension and the main app access to the same NSUserDefaults suite and file storage area.
- Create the entitlements file for your main app. In Rider, add a new file at Recall/Platforms/iOS/Entitlements.plist with this content:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Enable App Group for sharing data with the Share Extension -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.recall.shared</string>
</array>
</dict>
</plist>
What is this entitlement?
The com.apple.security.application-groups entitlement registers your app in a shared group. Both your main app and your Share Extension declare the same group identifier (group.com.recall.shared), which gives them access to a shared container for passing data back and forth.
- Now create the Share Extension folder and view controller. Create a new folder called ShareExtension inside the Recall project.
- Inside the ShareExtension folder, create a new file called ShareViewController.cs with the following code:
using Foundation;
using Social;
using UIKit;
using UniformTypeIdentifiers;
namespace Recall.ShareExtension;
// Share Extension view controller that receives URLs and images
[Register("ShareViewController")]
public class ShareViewController : SLComposeServiceViewController
{
private const string AppGroupIdentifier = "group.com.recall.shared";
private const string SharedUrlKey = "shared_url";
private const string SharedImageKey = "shared_image";
private const string CustomUrlScheme = "recall";
public override void DidSelectPost()
{
// Process all attachments from the share sheet
var extensionItem = ExtensionContext?.InputItems?.FirstOrDefault();
if (extensionItem?.Attachments == null)
{
CompleteExtension();
return;
}
foreach (var attachment in extensionItem.Attachments)
{
if (attachment.HasItemConformingTo(UTTypes.Url.Identifier))
{
// Handle shared URL
attachment.LoadItem(UTTypes.Url.Identifier, null, (data, error) =>
{
if (data is NSUrl url)
{
SaveUrlToAppGroup(url.AbsoluteString ?? "");
OpenMainApp();
}
CompleteExtension();
});
return;
}
else if (attachment.HasItemConformingTo(UTTypes.Image.Identifier))
{
// Handle shared image
attachment.LoadItem(UTTypes.Image.Identifier, null, (data, error) =>
{
if (data is NSUrl imageUrl)
{
var imageData = NSData.FromUrl(imageUrl);
if (imageData != null)
{
SaveImageToAppGroup(imageData);
OpenMainApp();
}
}
CompleteExtension();
});
return;
}
}
CompleteExtension();
}
// Store URL in shared App Group defaults
private void SaveUrlToAppGroup(string url)
{
var defaults = new NSUserDefaults(AppGroupIdentifier, NSUserDefaultsType.SuiteName);
defaults.SetString(url, SharedUrlKey);
defaults.Synchronize();
}
// Store image data in shared App Group defaults
private void SaveImageToAppGroup(NSData imageData)
{
var defaults = new NSUserDefaults(AppGroupIdentifier, NSUserDefaultsType.SuiteName);
defaults.SetValueForKey(imageData, new NSString(SharedImageKey));
defaults.Synchronize();
}
// Launch the main app via custom URL scheme
private void OpenMainApp()
{
var url = new NSUrl($"{CustomUrlScheme}://share");
UIResponder responder = this;
while (responder != null)
{
if (responder is UIApplication application)
{
application.OpenUrl(url, new UIApplicationOpenUrlOptions(), null);
return;
}
responder = responder.NextResponder!;
}
}
// Complete the extension request
private void CompleteExtension()
{
ExtensionContext?.CompleteRequest(Array.Empty<NSExtensionItem>(), null);
}
public override bool IsContentValid()
{
return true;
}
}
What does ShareViewController do?
When a user shares content from another app, iOS calls DidSelectPost(). The extension checks if the attachment is a URL or an image. It stores the shared data in the App Group container using NSUserDefaults, then opens the main Recall app via a custom recall://share URL.
The main app's AppDelegate detects the custom URL, reads from the shared container, and sends the data to the backend for AI analysis.
- Register the custom URL scheme in your app. Open Recall/Platforms/iOS/Info.plist and add the following inside the top-level <dict>:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.recall.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>recall</string>
</array>
</dict>
</array>
- Save all files: Entitlements.plist, ShareViewController.cs, and the updated Info.plist.
- Build the project. The Share Extension will appear in the system share sheet once you deploy to a device or simulator with the correct provisioning profile.
Testing the Share Extension
To test the extension locally, deploy to an iOS simulator and open Safari. Navigate to any website, tap the Share button, and look for Recall in the app list. You may need to tap More and enable Recall the first time.
Share Extension not appearing in the share sheet?
Make sure the App Group identifier group.com.recall.shared matches exactly in both Entitlements.plist and ShareViewController.cs.
Verify that your Apple Developer account has the App Groups capability enabled for your app identifier. You can check this in the Apple Developer portal under Certificates, Identifiers & Profiles.
If the extension project is not building separately, you may need to add an iOS extension target in Xcode and reference it from your MAUI project.
Help me set up the Recall Share Extension
All three save creation paths are now wired up. Users can paste a link, upload a screenshot from the gallery or camera, or share content from any other app. Next, you will add notifications and a daily digest to close the save-to-action loop.
Implement Notifications and Daily Digest
Your Recall app can now save links and images, analyze them with AI, and sync across devices. The missing piece is the nudge that brings you back.
Without a reminder system, saved items pile up and never get acted on. In this step, you will close the loop by scheduling daily digest notifications that surface your recent saves and let you mark them complete right from the digest view.
In this step, get ready to:
- Schedule recurring daily notifications using UNUserNotificationCenter.
- Build a digest page that shows recent unactioned saves with quick-complete buttons.
- Add a completed archive tab with stats tracking.
Create the notification service
iOS and Mac Catalyst use Apple's UNUserNotificationCenter to manage local notifications. Your service needs to request permission on first launch, then schedule a recurring daily trigger at the user's preferred time.
- In Rider, right-click the Services folder inside your Recall project.
- Select Add then New File.
- Name the file NotificationService.cs and paste the following code:
using Foundation;
using UserNotifications;
namespace Recall.Services;
public class NotificationService
{
private bool _hasPermission;
// Request notification permission on first launch
public void RequestPermission()
{
UNUserNotificationCenter.Current.RequestAuthorization(
UNAuthorizationOptions.Alert | UNAuthorizationOptions.Badge | UNAuthorizationOptions.Sound,
(approved, error) =>
{
_hasPermission = approved;
});
}
// Schedule a recurring daily notification at the specified hour
public void ScheduleDailyDigest(int hour = 9, int minute = 0)
{
if (!_hasPermission)
return;
// Build the notification content
var content = new UNMutableNotificationContent()
{
Title = "Recall Daily Digest",
Body = "You have unactioned saves waiting. Time to follow through!",
Sound = UNNotificationSound.Default
};
// Create a calendar trigger that repeats daily at the chosen time
var dateComponents = new NSDateComponents()
{
Hour = hour,
Minute = minute
};
var trigger = UNCalendarNotificationTrigger.CreateTrigger(dateComponents, true);
// Register the notification request
var request = UNNotificationRequest.FromIdentifier(
"recall-daily-digest", content, trigger);
UNUserNotificationCenter.Current.AddNotificationRequest(request, (error) =>
{
if (error != null)
Console.WriteLine($"Failed to schedule digest notification: {error}");
});
}
// Cancel the existing daily digest (e.g. when user changes time)
public void CancelDailyDigest()
{
UNUserNotificationCenter.Current.RemovePendingNotificationRequests(
new[] { "recall-daily-digest" });
}
}
What does this code do?
RequestPermission() asks the user to allow notifications using UNUserNotificationCenter.Current.RequestAuthorization. This triggers the system permission dialog on first launch.
ScheduleDailyDigest() creates a UNCalendarNotificationTrigger with repeats: true. This means the notification fires every day at the specified hour and minute (defaulting to 9:00 AM).
CancelDailyDigest() removes the pending request by its identifier, letting you reschedule at a new time without duplicating notifications.
- Save NotificationService.cs.
- Open App.xaml.cs (or your app startup file) and add calls to request permission and schedule the digest on launch:
// In your App constructor or OnStart method
var notificationService = new NotificationService();
notificationService.RequestPermission();
notificationService.ScheduleDailyDigest(hour: 9, minute: 0);
Why schedule on every launch?
UNCalendarNotificationTrigger with repeats: true persists across app restarts on iOS and Mac Catalyst. Calling ScheduleDailyDigest() again with the same identifier simply updates the existing request rather than creating a duplicate.
- Save App.xaml.cs.
- Build the project in Rider (Cmd+B). Confirm there are no compilation errors.
Seeing a build error about UserNotifications?
Make sure your project targets net10.0-ios and net10.0-maccatalyst in the .csproj file. The UserNotifications namespace is only available on Apple platforms.
If Rider cannot resolve UNUserNotificationCenter, try File then Invalidate Caches and restart.
Help me fix a UserNotifications build error in my MAUI project
Build the digest page
When the daily notification fires, you want the user to land on a focused view showing only recent unactioned saves. This page pulls data from the GET /api/saves/digest endpoint and renders quick-complete buttons so the user can act without navigating to each save individually.
- Right-click the Recall project root in Rider.
- Select Add then New File.
- Name the file DigestPage.xaml and add the following XAML:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Recall.DigestPage"
Title="Daily Digest">
<ScrollView Padding="16">
<VerticalStackLayout Spacing="12">
<!-- Header with date context -->
<Label Text="Recent Unactioned Saves"
FontSize="22"
FontAttributes="Bold" />
<Label x:Name="DigestSubtitle"
Text="From the last 24 hours"
FontSize="14"
TextColor="Gray" />
<!-- Digest items list -->
<CollectionView x:Name="DigestCollection"
ItemsLayout="VerticalList"
SelectionMode="None">
<CollectionView.ItemTemplate>
<DataTemplate>
<Frame Padding="12"
Margin="0,4"
CornerRadius="10"
BorderColor="LightGray"
HasShadow="False">
<Grid ColumnDefinitions="*, Auto">
<!-- Save summary and category -->
<VerticalStackLayout Grid.Column="0" Spacing="4">
<Label Text="{Binding AiSummary}"
FontSize="15"
LineBreakMode="TailTruncation"
MaxLines="2" />
<Label Text="{Binding AiCategory}"
FontSize="12"
TextColor="Gray" />
</VerticalStackLayout>
<!-- Quick complete button -->
<Button Grid.Column="1"
Text="Done"
Clicked="OnCompleteClicked"
BackgroundColor="#4CAF50"
TextColor="White"
CornerRadius="8"
HeightRequest="36"
WidthRequest="64"
FontSize="13" />
</Grid>
</Frame>
</DataTemplate>
</CollectionView.ItemTemplate>
<!-- Empty state -->
<CollectionView.EmptyView>
<Label Text="All caught up! No unactioned saves in the last 24 hours."
HorizontalOptions="Center"
VerticalOptions="Center"
FontSize="16"
TextColor="Gray"
Margin="0,40" />
</CollectionView.EmptyView>
</CollectionView>
</VerticalStackLayout>
</ScrollView>
</ContentPage>
- Now create the code-behind file DigestPage.xaml.cs with the following logic:
using Recall.Services;
namespace Recall;
public partial class DigestPage : ContentPage
{
private readonly ApiService _apiService;
private List<SaveItem> _digestItems = new();
public DigestPage()
{
InitializeComponent();
_apiService = new ApiService();
}
// Load digest data when page appears
protected override async void OnAppearing()
{
base.OnAppearing();
await LoadDigest();
}
private async Task LoadDigest()
{
try
{
// Call the digest endpoint grouped by category
var digestGroups = await _apiService.GetDigestAsync();
// Flatten grouped results into a single list for display
_digestItems = digestGroups.SelectMany(g => g.Value).ToList();
DigestCollection.ItemsSource = _digestItems;
}
catch (Exception ex)
{
await DisplayAlert("Error", $"Could not load digest: {ex.Message}", "OK");
}
}
// Handle quick-complete button tap
private async void OnCompleteClicked(object sender, EventArgs e)
{
if (sender is Button button && button.BindingContext is SaveItem save)
{
await _apiService.CompleteSaveAsync(save.Id);
// Remove from local list and refresh
_digestItems.Remove(save);
DigestCollection.ItemsSource = null;
DigestCollection.ItemsSource = _digestItems;
}
}
}
What does this page do?
OnAppearing() calls GetDigestAsync() from your existing ApiService. This hits GET /api/saves/digest on the backend, which returns saves from the last 24 hours grouped by AI category.
The quick-complete button calls CompleteSaveAsync() (hitting PATCH /api/saves/{id}/complete) and immediately removes the item from the list, giving instant visual feedback.
- Save both DigestPage.xaml and DigestPage.xaml.cs.
- Build the project to confirm no errors.
Seeing errors about SaveItem or GetDigestAsync?
Make sure your ApiService.cs already has the GetDigestAsync() method and that you have a SaveItem model class with properties like Id, AiSummary, and AiCategory. These were created in earlier steps. Check that the namespace imports are correct.
Help me fix digest page errors in my Recall MAUI project
Add the completed archive tab
The final piece is a dedicated tab where users can see everything they have followed through on. This provides a satisfying sense of accomplishment and helps track productivity over time.
- Create a new file called CompletedPage.xaml in the Recall project root:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Recall.CompletedPage"
Title="Completed">
<ScrollView Padding="16">
<VerticalStackLayout Spacing="12">
<!-- Stats bar -->
<Frame Padding="16"
CornerRadius="12"
BackgroundColor="#F0F8F0"
BorderColor="Transparent"
HasShadow="False">
<HorizontalStackLayout Spacing="24" HorizontalOptions="Center">
<VerticalStackLayout HorizontalOptions="Center">
<Label x:Name="WeekCountLabel"
Text="0"
FontSize="28"
FontAttributes="Bold"
HorizontalOptions="Center" />
<Label Text="This Week"
FontSize="12"
TextColor="Gray"
HorizontalOptions="Center" />
</VerticalStackLayout>
<VerticalStackLayout HorizontalOptions="Center">
<Label x:Name="CompletionRateLabel"
Text="0%"
FontSize="28"
FontAttributes="Bold"
HorizontalOptions="Center" />
<Label Text="Completion Rate"
FontSize="12"
TextColor="Gray"
HorizontalOptions="Center" />
</VerticalStackLayout>
</HorizontalStackLayout>
</Frame>
<!-- Completed saves list -->
<Label Text="Completed Saves"
FontSize="18"
FontAttributes="Bold"
Margin="0,8,0,0" />
<CollectionView x:Name="CompletedCollection"
ItemsLayout="VerticalList"
SelectionMode="None">
<CollectionView.ItemTemplate>
<DataTemplate>
<Frame Padding="12"
Margin="0,4"
CornerRadius="10"
BorderColor="LightGray"
HasShadow="False">
<VerticalStackLayout Spacing="4">
<Label Text="{Binding AiSummary}"
FontSize="15"
LineBreakMode="TailTruncation"
MaxLines="2" />
<HorizontalStackLayout Spacing="8">
<Label Text="{Binding AiCategory}"
FontSize="12"
TextColor="Gray" />
<Label Text="{Binding CompletedAt, StringFormat='Completed {0:MMM dd}'}"
FontSize="12"
TextColor="#4CAF50" />
</HorizontalStackLayout>
</VerticalStackLayout>
</Frame>
</DataTemplate>
</CollectionView.ItemTemplate>
<CollectionView.EmptyView>
<Label Text="No completed saves yet. Start checking things off!"
HorizontalOptions="Center"
VerticalOptions="Center"
FontSize="16"
TextColor="Gray"
Margin="0,40" />
</CollectionView.EmptyView>
</CollectionView>
</VerticalStackLayout>
</ScrollView>
</ContentPage>
- Create the code-behind file CompletedPage.xaml.cs:
using Recall.Services;
namespace Recall;
public partial class CompletedPage : ContentPage
{
private readonly ApiService _apiService;
public CompletedPage()
{
InitializeComponent();
_apiService = new ApiService();
}
// Refresh data each time page appears
protected override async void OnAppearing()
{
base.OnAppearing();
await LoadCompleted();
}
private async Task LoadCompleted()
{
try
{
// Fetch completed saves from the backend
var completedSaves = await _apiService.GetCompletedSavesAsync();
CompletedCollection.ItemsSource = completedSaves;
// Calculate stats
var thisWeek = completedSaves
.Where(s => s.CompletedAt >= DateTime.UtcNow.AddDays(-7))
.Count();
var allSaves = await _apiService.GetSavesAsync();
var totalSaves = allSaves.Count + completedSaves.Count;
var rate = totalSaves > 0
? (int)((double)completedSaves.Count / totalSaves * 100)
: 0;
// Update stats bar
WeekCountLabel.Text = thisWeek.ToString();
CompletionRateLabel.Text = $"{rate}%";
}
catch (Exception ex)
{
await DisplayAlert("Error", $"Could not load completed saves: {ex.Message}", "OK");
}
}
}
How do the stats work?
The stats bar shows two numbers. "This Week" counts saves completed in the last 7 days. "Completion Rate" divides total completed saves by all saves (active plus completed) to give a percentage.
GetCompletedSavesAsync() calls GET /api/saves/completed on your backend, which returns saves with status COMPLETED ordered by completion date.
- Save both CompletedPage.xaml and CompletedPage.xaml.cs.
- Now update AppShell.xaml to wire all three views together as tabs:
<?xml version="1.0" encoding="utf-8" ?>
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Recall"
x:Class="Recall.AppShell">
<TabBar>
<!-- Main saves list -->
<ShellContent Title="Saves"
ContentTemplate="{DataTemplate local:MainPage}" />
<!-- Daily digest view -->
<ShellContent Title="Digest"
ContentTemplate="{DataTemplate local:DigestPage}" />
<!-- Completed archive -->
<ShellContent Title="Completed"
ContentTemplate="{DataTemplate local:CompletedPage}" />
</TabBar>
</Shell>
What does AppShell do?
MAUI's Shell provides tab-based navigation out of the box. Each ShellContent inside a TabBar becomes a tab at the bottom of the app. The three tabs give quick access to active saves, the daily digest, and completed items.
- Save AppShell.xaml.
- Build and run the app on the Mac Catalyst target (Cmd+R in Rider).
- You should see three tabs at the bottom: Saves, Digest, and Completed.
- Tap the Digest tab. If you have saves from the last 24 hours, they appear with green Done buttons.
- Tap Done on a save, then switch to the Completed tab to see it appear with its completion date.
Tabs not appearing?
Make sure AppShell.xaml is set as the app's main shell. In App.xaml.cs, your constructor should set MainPage = new AppShell();.
If the app launches to a blank screen, check that DigestPage and CompletedPage have matching x:Class values in their XAML and .cs files.
Help me debug missing tabs in my MAUI AppShell
On first launch, the app will ask for notification permission. Accept it to enable the daily digest reminder. At 9:00 AM each day, you will receive a notification that takes you directly to the Digest tab.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all four files: NotificationService.cs, DigestPage.xaml, CompletedPage.xaml, and AppShell.xaml.
ⓧ I'd like to double check the full code
Here is the full code for each new file created in this step.
using Foundation;
using UserNotifications;
namespace Recall.Services;
public class NotificationService
{
private bool _hasPermission;
// Request notification permission on first launch
public void RequestPermission()
{
UNUserNotificationCenter.Current.RequestAuthorization(
UNAuthorizationOptions.Alert | UNAuthorizationOptions.Badge | UNAuthorizationOptions.Sound,
(approved, error) =>
{
_hasPermission = approved;
});
}
// Schedule a recurring daily notification at the specified hour
public void ScheduleDailyDigest(int hour = 9, int minute = 0)
{
if (!_hasPermission)
return;
// Build the notification content
var content = new UNMutableNotificationContent()
{
Title = "Recall Daily Digest",
Body = "You have unactioned saves waiting. Time to follow through!",
Sound = UNNotificationSound.Default
};
// Create a calendar trigger that repeats daily at the chosen time
var dateComponents = new NSDateComponents()
{
Hour = hour,
Minute = minute
};
var trigger = UNCalendarNotificationTrigger.CreateTrigger(dateComponents, true);
// Register the notification request
var request = UNNotificationRequest.FromIdentifier(
"recall-daily-digest", content, trigger);
UNUserNotificationCenter.Current.AddNotificationRequest(request, (error) =>
{
if (error != null)
Console.WriteLine($"Failed to schedule digest notification: {error}");
});
}
// Cancel the existing daily digest (e.g. when user changes time)
public void CancelDailyDigest()
{
UNUserNotificationCenter.Current.RemovePendingNotificationRequests(
new[] { "recall-daily-digest" });
}
}
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Recall.DigestPage"
Title="Daily Digest">
<ScrollView Padding="16">
<VerticalStackLayout Spacing="12">
<!-- Header with date context -->
<Label Text="Recent Unactioned Saves"
FontSize="22"
FontAttributes="Bold" />
<Label x:Name="DigestSubtitle"
Text="From the last 24 hours"
FontSize="14"
TextColor="Gray" />
<!-- Digest items list -->
<CollectionView x:Name="DigestCollection"
ItemsLayout="VerticalList"
SelectionMode="None">
<CollectionView.ItemTemplate>
<DataTemplate>
<Frame Padding="12"
Margin="0,4"
CornerRadius="10"
BorderColor="LightGray"
HasShadow="False">
<Grid ColumnDefinitions="*, Auto">
<!-- Save summary and category -->
<VerticalStackLayout Grid.Column="0" Spacing="4">
<Label Text="{Binding AiSummary}"
FontSize="15"
LineBreakMode="TailTruncation"
MaxLines="2" />
<Label Text="{Binding AiCategory}"
FontSize="12"
TextColor="Gray" />
</VerticalStackLayout>
<!-- Quick complete button -->
<Button Grid.Column="1"
Text="Done"
Clicked="OnCompleteClicked"
BackgroundColor="#4CAF50"
TextColor="White"
CornerRadius="8"
HeightRequest="36"
WidthRequest="64"
FontSize="13" />
</Grid>
</Frame>
</DataTemplate>
</CollectionView.ItemTemplate>
<!-- Empty state -->
<CollectionView.EmptyView>
<Label Text="All caught up! No unactioned saves in the last 24 hours."
HorizontalOptions="Center"
VerticalOptions="Center"
FontSize="16"
TextColor="Gray"
Margin="0,40" />
</CollectionView.EmptyView>
</CollectionView>
</VerticalStackLayout>
</ScrollView>
</ContentPage>
using Recall.Services;
namespace Recall;
public partial class DigestPage : ContentPage
{
private readonly ApiService _apiService;
private List<SaveItem> _digestItems = new();
public DigestPage()
{
InitializeComponent();
_apiService = new ApiService();
}
// Load digest data when page appears
protected override async void OnAppearing()
{
base.OnAppearing();
await LoadDigest();
}
private async Task LoadDigest()
{
try
{
// Call the digest endpoint grouped by category
var digestGroups = await _apiService.GetDigestAsync();
// Flatten grouped results into a single list for display
_digestItems = digestGroups.SelectMany(g => g.Value).ToList();
DigestCollection.ItemsSource = _digestItems;
}
catch (Exception ex)
{
await DisplayAlert("Error", $"Could not load digest: {ex.Message}", "OK");
}
}
// Handle quick-complete button tap
private async void OnCompleteClicked(object sender, EventArgs e)
{
if (sender is Button button && button.BindingContext is SaveItem save)
{
await _apiService.CompleteSaveAsync(save.Id);
// Remove from local list and refresh
_digestItems.Remove(save);
DigestCollection.ItemsSource = null;
DigestCollection.ItemsSource = _digestItems;
}
}
}
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Recall.CompletedPage"
Title="Completed">
<ScrollView Padding="16">
<VerticalStackLayout Spacing="12">
<!-- Stats bar -->
<Frame Padding="16"
CornerRadius="12"
BackgroundColor="#F0F8F0"
BorderColor="Transparent"
HasShadow="False">
<HorizontalStackLayout Spacing="24" HorizontalOptions="Center">
<VerticalStackLayout HorizontalOptions="Center">
<Label x:Name="WeekCountLabel"
Text="0"
FontSize="28"
FontAttributes="Bold"
HorizontalOptions="Center" />
<Label Text="This Week"
FontSize="12"
TextColor="Gray"
HorizontalOptions="Center" />
</VerticalStackLayout>
<VerticalStackLayout HorizontalOptions="Center">
<Label x:Name="CompletionRateLabel"
Text="0%"
FontSize="28"
FontAttributes="Bold"
HorizontalOptions="Center" />
<Label Text="Completion Rate"
FontSize="12"
TextColor="Gray"
HorizontalOptions="Center" />
</VerticalStackLayout>
</HorizontalStackLayout>
</Frame>
<!-- Completed saves list -->
<Label Text="Completed Saves"
FontSize="18"
FontAttributes="Bold"
Margin="0,8,0,0" />
<CollectionView x:Name="CompletedCollection"
ItemsLayout="VerticalList"
SelectionMode="None">
<CollectionView.ItemTemplate>
<DataTemplate>
<Frame Padding="12"
Margin="0,4"
CornerRadius="10"
BorderColor="LightGray"
HasShadow="False">
<VerticalStackLayout Spacing="4">
<Label Text="{Binding AiSummary}"
FontSize="15"
LineBreakMode="TailTruncation"
MaxLines="2" />
<HorizontalStackLayout Spacing="8">
<Label Text="{Binding AiCategory}"
FontSize="12"
TextColor="Gray" />
<Label Text="{Binding CompletedAt, StringFormat='Completed {0:MMM dd}'}"
FontSize="12"
TextColor="#4CAF50" />
</HorizontalStackLayout>
</VerticalStackLayout>
</Frame>
</DataTemplate>
</CollectionView.ItemTemplate>
<CollectionView.EmptyView>
<Label Text="No completed saves yet. Start checking things off!"
HorizontalOptions="Center"
VerticalOptions="Center"
FontSize="16"
TextColor="Gray"
Margin="0,40" />
</CollectionView.EmptyView>
</CollectionView>
</VerticalStackLayout>
</ScrollView>
</ContentPage>
using Recall.Services;
namespace Recall;
public partial class CompletedPage : ContentPage
{
private readonly ApiService _apiService;
public CompletedPage()
{
InitializeComponent();
_apiService = new ApiService();
}
// Refresh data each time page appears
protected override async void OnAppearing()
{
base.OnAppearing();
await LoadCompleted();
}
private async Task LoadCompleted()
{
try
{
// Fetch completed saves from the backend
var completedSaves = await _apiService.GetCompletedSavesAsync();
CompletedCollection.ItemsSource = completedSaves;
// Calculate stats
var thisWeek = completedSaves
.Where(s => s.CompletedAt >= DateTime.UtcNow.AddDays(-7))
.Count();
var allSaves = await _apiService.GetSavesAsync();
var totalSaves = allSaves.Count + completedSaves.Count;
var rate = totalSaves > 0
? (int)((double)completedSaves.Count / totalSaves * 100)
: 0;
// Update stats bar
WeekCountLabel.Text = thisWeek.ToString();
CompletionRateLabel.Text = $"{rate}%";
}
catch (Exception ex)
{
await DisplayAlert("Error", $"Could not load completed saves: {ex.Message}", "OK");
}
}
}
<?xml version="1.0" encoding="utf-8" ?>
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Recall"
x:Class="Recall.AppShell">
<TabBar>
<!-- Main saves list -->
<ShellContent Title="Saves"
ContentTemplate="{DataTemplate local:MainPage}" />
<!-- Daily digest view -->
<ShellContent Title="Digest"
ContentTemplate="{DataTemplate local:DigestPage}" />
<!-- Completed archive -->
<ShellContent Title="Completed"
ContentTemplate="{DataTemplate local:CompletedPage}" />
</TabBar>
</Shell>
The save-analyze-notify-complete loop is now fully functional. Saves come in from links or screenshots, AI categorizes them, the daily notification reminds you, and you mark them done from the digest. Next, you will tackle the Secret Mission to add automatic screenshot folder scanning.
Secret mission
Add Screenshot Folder Auto-Scanning
What if Recall could watch your Screenshots folder and automatically import new screenshots without you lifting a finger? In this secret mission, you'll build a folder watcher that detects new screenshots on your Mac, uploads them to your backend for AI analysis, and shows the results in a dedicated auto-scan section of the app.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with a Docker-managed PostgreSQL container. There are no cloud resources or ongoing costs to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- PostgreSQL Docker container (recall-postgres).
- Docker volume (recall-data).
- Spring Boot backend project folder (recall-api).
- .NET MAUI frontend project folder (Recall).
- Environment variable GOOGLE_API_KEY.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with Recall.
- Your recall-postgres container stays running in Docker Desktop. Cost: $0.
- Your project folders remain on your Desktop ready for editing.
- Your GOOGLE_API_KEY environment variable stays set for Gemini API calls.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your data and files stay intact for when you return.
- Stop the PostgreSQL container by running:
- Your data persists in the Docker volume. Restart later with docker start recall-postgres.
docker stop recall-postgres
- Stop the Spring Boot backend by pressing Ctrl+C in the terminal where it is running.
- Your project folders and environment variable remain untouched.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Stop and remove the PostgreSQL container, then delete its data volume by running:
docker stop recall-postgres
docker rm recall-postgres
docker volume rm recall-data
- Delete the backend project folder by running:
cd ~/Desktop
rm -rf recall-api
- Delete the MAUI frontend project folder by running:
rm -rf Recall
- Remove the Gemini API key environment variable from your current session by running:
unset GOOGLE_API_KEY
Permanent unset
If you added GOOGLE_API_KEY to your shell profile (~/.zshrc or ~/.bash_profile), open the file and remove that line as well.
- No cloud resources to clean up. Everything ran locally.
Nice Work!
Nice Work!
Nice work! You just built Recall, a full-stack AI-powered save-and-act app that spans a Spring Boot backend, a native .NET MAUI frontend, and a Gemini AI integration. You solved a real user problem: bridging the gap between saving something and actually acting on it.
You've learned how to:
- Build a Spring Boot 4.0.6 REST API backed by PostgreSQL and integrate Gemini 3.5 Flash to automatically analyze and categorize saves with zero cost.
- Build a native .NET MAUI app running on both Mac Catalyst and iOS with a share extension for frictionless saving from any app.
- Implement a complete save-analyze-notify-complete loop using JWT authentication for cross-device sync and daily digest local notifications that close the gap between saving and acting.
- Add screenshot folder auto-scanning that watches a folder for new screenshots and automatically imports them with AI analysis for truly effortless capture.
Ready to quiz yourself?