diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml
new file mode 100644
index 0000000..741b113
--- /dev/null
+++ b/.github/workflows/build-apk.yml
@@ -0,0 +1,63 @@
+name: Build Android APK
+
+on:
+ push:
+ branches: [ main, develop ]
+ pull_request:
+ branches: [ main ]
+ workflow_dispatch:
+
+env:
+ DOTNET_VERSION: '8.0.x'
+ PROJECT_PATH: src/VoiceNotesAI/VoiceNotesAI.csproj
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
+
+ - name: Install MAUI workload
+ run: dotnet workload install maui-android
+
+ - name: Setup Java JDK
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'microsoft'
+ java-version: '17'
+
+ - name: Create appsettings.json from secret
+ run: |
+ cat > src/VoiceNotesAI/appsettings.json << 'SETTINGS'
+ {
+ "OpenAI": {
+ "ApiKey": "${{ secrets.OPENAI_API_KEY }}",
+ "Model": "gpt-4o",
+ "WhisperModel": "whisper-1"
+ }
+ }
+ SETTINGS
+
+ - name: Restore dependencies
+ run: dotnet restore ${{ env.PROJECT_PATH }}
+
+ - name: Build APK
+ run: >
+ dotnet publish ${{ env.PROJECT_PATH }}
+ -c Release
+ -f net8.0-android
+ -o output
+
+ - name: Upload APK artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: VoiceNotesAI-APK
+ path: output/**/*-Signed.apk
+ if-no-files-found: warn
diff --git a/.gitignore b/.gitignore
index ce89292..24df022 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@
*.userosscache
*.sln.docstates
*.env
+appsettings.json
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
diff --git a/VoiceNotesAI.sln b/VoiceNotesAI.sln
new file mode 100644
index 0000000..0b71bed
--- /dev/null
+++ b/VoiceNotesAI.sln
@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VoiceNotesAI", "src\VoiceNotesAI\VoiceNotesAI.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VoiceNotesAI.Tests", "tests\VoiceNotesAI.Tests\VoiceNotesAI.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/src/VoiceNotesAI/App.xaml b/src/VoiceNotesAI/App.xaml
new file mode 100644
index 0000000..e6d9b5e
--- /dev/null
+++ b/src/VoiceNotesAI/App.xaml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/VoiceNotesAI/App.xaml.cs b/src/VoiceNotesAI/App.xaml.cs
new file mode 100644
index 0000000..ec63e34
--- /dev/null
+++ b/src/VoiceNotesAI/App.xaml.cs
@@ -0,0 +1,18 @@
+using VoiceNotesAI.Data;
+
+namespace VoiceNotesAI;
+
+public partial class App : Application
+{
+ public App(AppDatabase database)
+ {
+ InitializeComponent();
+
+ Task.Run(async () => await database.InitializeAsync());
+ }
+
+ protected override Window CreateWindow(IActivationState? activationState)
+ {
+ return new Window(new AppShell());
+ }
+}
diff --git a/src/VoiceNotesAI/AppShell.xaml b/src/VoiceNotesAI/AppShell.xaml
new file mode 100644
index 0000000..7032d4b
--- /dev/null
+++ b/src/VoiceNotesAI/AppShell.xaml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/src/VoiceNotesAI/AppShell.xaml.cs b/src/VoiceNotesAI/AppShell.xaml.cs
new file mode 100644
index 0000000..d61a62b
--- /dev/null
+++ b/src/VoiceNotesAI/AppShell.xaml.cs
@@ -0,0 +1,15 @@
+using VoiceNotesAI.Pages;
+
+namespace VoiceNotesAI;
+
+public partial class AppShell : Shell
+{
+ public AppShell()
+ {
+ InitializeComponent();
+
+ Routing.RegisterRoute("RecordingPage", typeof(RecordingPage));
+ Routing.RegisterRoute("NoteResultPage", typeof(NoteResultPage));
+ Routing.RegisterRoute("NoteDetailPage", typeof(NoteDetailPage));
+ }
+}
diff --git a/src/VoiceNotesAI/Converters/InvertedBoolConverter.cs b/src/VoiceNotesAI/Converters/InvertedBoolConverter.cs
new file mode 100644
index 0000000..018872d
--- /dev/null
+++ b/src/VoiceNotesAI/Converters/InvertedBoolConverter.cs
@@ -0,0 +1,20 @@
+using System.Globalization;
+
+namespace VoiceNotesAI.Converters;
+
+public class InvertedBoolConverter : IValueConverter
+{
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is bool boolValue)
+ return !boolValue;
+ return value;
+ }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is bool boolValue)
+ return !boolValue;
+ return value;
+ }
+}
diff --git a/src/VoiceNotesAI/Converters/RecordButtonColorConverter.cs b/src/VoiceNotesAI/Converters/RecordButtonColorConverter.cs
new file mode 100644
index 0000000..cab6332
--- /dev/null
+++ b/src/VoiceNotesAI/Converters/RecordButtonColorConverter.cs
@@ -0,0 +1,18 @@
+using System.Globalization;
+
+namespace VoiceNotesAI.Converters;
+
+public class RecordButtonColorConverter : IValueConverter
+{
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is bool isRecording)
+ return isRecording ? Color.FromArgb("#E53935") : Color.FromArgb("#6750A4");
+ return Color.FromArgb("#6750A4");
+ }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/src/VoiceNotesAI/Converters/RecordButtonTextConverter.cs b/src/VoiceNotesAI/Converters/RecordButtonTextConverter.cs
new file mode 100644
index 0000000..98d66b0
--- /dev/null
+++ b/src/VoiceNotesAI/Converters/RecordButtonTextConverter.cs
@@ -0,0 +1,18 @@
+using System.Globalization;
+
+namespace VoiceNotesAI.Converters;
+
+public class RecordButtonTextConverter : IValueConverter
+{
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is bool isRecording)
+ return isRecording ? "⏹️ Parar Gravação" : "🎙️ Iniciar Gravação";
+ return "🎙️ Iniciar Gravação";
+ }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/src/VoiceNotesAI/Data/AppDatabase.cs b/src/VoiceNotesAI/Data/AppDatabase.cs
new file mode 100644
index 0000000..c070b8d
--- /dev/null
+++ b/src/VoiceNotesAI/Data/AppDatabase.cs
@@ -0,0 +1,46 @@
+using SQLite;
+using VoiceNotesAI.Models;
+
+namespace VoiceNotesAI.Data;
+
+public class AppDatabase
+{
+ private readonly SQLiteAsyncConnection _database;
+
+ public AppDatabase(string dbPath)
+ {
+ _database = new SQLiteAsyncConnection(dbPath);
+ }
+
+ public SQLiteAsyncConnection Connection => _database;
+
+ public async Task InitializeAsync()
+ {
+ await _database.CreateTableAsync();
+ await _database.CreateTableAsync();
+
+ await SeedCategoriesAsync();
+ }
+
+ private async Task SeedCategoriesAsync()
+ {
+ var count = await _database.Table().CountAsync();
+ if (count > 0)
+ return;
+
+ var defaultCategories = new[]
+ {
+ "Tarefas",
+ "Ideias",
+ "Lembretes",
+ "Trabalho",
+ "Pessoal",
+ "Outros"
+ };
+
+ foreach (var name in defaultCategories)
+ {
+ await _database.InsertAsync(new Category { Name = name });
+ }
+ }
+}
diff --git a/src/VoiceNotesAI/Helpers/AppSettings.cs b/src/VoiceNotesAI/Helpers/AppSettings.cs
new file mode 100644
index 0000000..1cda90a
--- /dev/null
+++ b/src/VoiceNotesAI/Helpers/AppSettings.cs
@@ -0,0 +1,8 @@
+namespace VoiceNotesAI.Helpers;
+
+public class OpenAISettings
+{
+ public string ApiKey { get; set; } = string.Empty;
+ public string Model { get; set; } = "gpt-4o";
+ public string WhisperModel { get; set; } = "whisper-1";
+}
diff --git a/src/VoiceNotesAI/Helpers/PromptTemplates.cs b/src/VoiceNotesAI/Helpers/PromptTemplates.cs
new file mode 100644
index 0000000..e6466e5
--- /dev/null
+++ b/src/VoiceNotesAI/Helpers/PromptTemplates.cs
@@ -0,0 +1,38 @@
+namespace VoiceNotesAI.Helpers;
+
+public static class PromptTemplates
+{
+ public const string NoteInterpretation = """
+ You are an intelligent note-taking assistant.
+
+ The user will provide a transcribed audio text in Portuguese (Brazil).
+
+ Your job is to:
+ 1. Understand the content and intent of the message
+ 2. Generate a structured note from it
+
+ Return ONLY a valid JSON object with this exact structure:
+
+ {
+ "title": "Short and objective title (max 60 characters)",
+ "description": "Full and clear description of the note, preserving the original meaning",
+ "category": "One of: Tarefas | Ideias | Lembretes | Trabalho | Pessoal | Outros"
+ }
+
+ Rules:
+ - Do NOT add any explanation outside the JSON
+ - Do NOT wrap in markdown code blocks
+ - Title must be concise and meaningful
+ - Category must match exactly one of the allowed values
+ - Always respond in Portuguese (Brazil)
+ - If the content is unclear, do your best to interpret the intent
+
+ Transcribed text:
+ {{TRANSCRIBED_TEXT}}
+ """;
+
+ public static string BuildNotePrompt(string transcribedText)
+ {
+ return NoteInterpretation.Replace("{{TRANSCRIBED_TEXT}}", transcribedText);
+ }
+}
diff --git a/src/VoiceNotesAI/MauiProgram.cs b/src/VoiceNotesAI/MauiProgram.cs
new file mode 100644
index 0000000..488bf11
--- /dev/null
+++ b/src/VoiceNotesAI/MauiProgram.cs
@@ -0,0 +1,86 @@
+using System.Reflection;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Plugin.Maui.Audio;
+using VoiceNotesAI.Data;
+using VoiceNotesAI.Helpers;
+using VoiceNotesAI.Services;
+using VoiceNotesAI.Pages;
+using VoiceNotesAI.ViewModels;
+
+namespace VoiceNotesAI;
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+ builder
+ .UseMauiApp()
+ .ConfigureFonts(fonts =>
+ {
+ fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
+ fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
+ });
+
+ // Configuration from embedded appsettings.json
+ var assembly = Assembly.GetExecutingAssembly();
+ using var stream = assembly.GetManifestResourceStream("VoiceNotesAI.appsettings.json");
+
+ var config = new ConfigurationBuilder()
+ .AddJsonStream(stream!)
+ .Build();
+
+ // Settings
+ var openAISettings = new OpenAISettings();
+ config.GetSection("OpenAI").Bind(openAISettings);
+ builder.Services.AddSingleton(openAISettings);
+
+ // Database
+ var dbPath = Path.Combine(FileSystem.AppDataDirectory, "voicenotesai.db3");
+ builder.Services.AddSingleton(new AppDatabase(dbPath));
+
+ // HttpClient
+ builder.Services.AddSingleton();
+
+ // Audio
+ builder.Services.AddSingleton(AudioManager.Current);
+ builder.Services.AddSingleton();
+
+ // AI Services
+ builder.Services.AddSingleton(sp =>
+ {
+ var http = sp.GetRequiredService();
+ var settings = sp.GetRequiredService();
+ return new SpeechToTextService(http, settings.ApiKey, settings.WhisperModel);
+ });
+
+ builder.Services.AddSingleton(sp =>
+ {
+ var http = sp.GetRequiredService();
+ var settings = sp.GetRequiredService();
+ return new AIService(http, settings.ApiKey, settings.Model);
+ });
+
+ // Repositories
+ builder.Services.AddSingleton();
+
+ // ViewModels
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+
+ // Pages
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+
+#if DEBUG
+ builder.Logging.AddDebug();
+#endif
+
+ return builder.Build();
+ }
+}
diff --git a/src/VoiceNotesAI/Models/Category.cs b/src/VoiceNotesAI/Models/Category.cs
new file mode 100644
index 0000000..fd1ac81
--- /dev/null
+++ b/src/VoiceNotesAI/Models/Category.cs
@@ -0,0 +1,11 @@
+using SQLite;
+
+namespace VoiceNotesAI.Models;
+
+public class Category
+{
+ [PrimaryKey, AutoIncrement]
+ public int Id { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+}
diff --git a/src/VoiceNotesAI/Models/Note.cs b/src/VoiceNotesAI/Models/Note.cs
new file mode 100644
index 0000000..c3aae53
--- /dev/null
+++ b/src/VoiceNotesAI/Models/Note.cs
@@ -0,0 +1,21 @@
+using SQLite;
+
+namespace VoiceNotesAI.Models;
+
+public class Note
+{
+ [PrimaryKey, AutoIncrement]
+ public int Id { get; set; }
+
+ public string Title { get; set; } = string.Empty;
+
+ public string Description { get; set; } = string.Empty;
+
+ public string Category { get; set; } = string.Empty;
+
+ public string AudioFilePath { get; set; } = string.Empty;
+
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/src/VoiceNotesAI/Models/NoteResult.cs b/src/VoiceNotesAI/Models/NoteResult.cs
new file mode 100644
index 0000000..280fb41
--- /dev/null
+++ b/src/VoiceNotesAI/Models/NoteResult.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace VoiceNotesAI.Models;
+
+public class NoteResult
+{
+ [JsonPropertyName("title")]
+ public string Title { get; set; } = string.Empty;
+
+ [JsonPropertyName("description")]
+ public string Description { get; set; } = string.Empty;
+
+ [JsonPropertyName("category")]
+ public string Category { get; set; } = string.Empty;
+}
diff --git a/src/VoiceNotesAI/Pages/NoteDetailPage.xaml b/src/VoiceNotesAI/Pages/NoteDetailPage.xaml
new file mode 100644
index 0000000..77dc35e
--- /dev/null
+++ b/src/VoiceNotesAI/Pages/NoteDetailPage.xaml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/VoiceNotesAI/Pages/NoteDetailPage.xaml.cs b/src/VoiceNotesAI/Pages/NoteDetailPage.xaml.cs
new file mode 100644
index 0000000..ff80f9b
--- /dev/null
+++ b/src/VoiceNotesAI/Pages/NoteDetailPage.xaml.cs
@@ -0,0 +1,14 @@
+using VoiceNotesAI.ViewModels;
+
+namespace VoiceNotesAI.Pages;
+
+public partial class NoteDetailPage : ContentPage
+{
+ public NoteDetailPage(NoteDetailViewModel viewModel)
+ {
+ InitializeComponent();
+ BindingContext = viewModel;
+
+ CategoryPicker.ItemsSource = NoteDetailViewModel.AvailableCategories;
+ }
+}
diff --git a/src/VoiceNotesAI/Pages/NoteListPage.xaml b/src/VoiceNotesAI/Pages/NoteListPage.xaml
new file mode 100644
index 0000000..a907a1c
--- /dev/null
+++ b/src/VoiceNotesAI/Pages/NoteListPage.xaml
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/VoiceNotesAI/Pages/NoteListPage.xaml.cs b/src/VoiceNotesAI/Pages/NoteListPage.xaml.cs
new file mode 100644
index 0000000..564ac15
--- /dev/null
+++ b/src/VoiceNotesAI/Pages/NoteListPage.xaml.cs
@@ -0,0 +1,20 @@
+using VoiceNotesAI.ViewModels;
+
+namespace VoiceNotesAI.Pages;
+
+public partial class NoteListPage : ContentPage
+{
+ private readonly NoteListViewModel _viewModel;
+
+ public NoteListPage(NoteListViewModel viewModel)
+ {
+ InitializeComponent();
+ BindingContext = _viewModel = viewModel;
+ }
+
+ protected override async void OnAppearing()
+ {
+ base.OnAppearing();
+ await _viewModel.LoadNotesCommand.ExecuteAsync(null);
+ }
+}
diff --git a/src/VoiceNotesAI/Pages/NoteResultPage.xaml b/src/VoiceNotesAI/Pages/NoteResultPage.xaml
new file mode 100644
index 0000000..2b24f6e
--- /dev/null
+++ b/src/VoiceNotesAI/Pages/NoteResultPage.xaml
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/VoiceNotesAI/Pages/NoteResultPage.xaml.cs b/src/VoiceNotesAI/Pages/NoteResultPage.xaml.cs
new file mode 100644
index 0000000..671afc2
--- /dev/null
+++ b/src/VoiceNotesAI/Pages/NoteResultPage.xaml.cs
@@ -0,0 +1,14 @@
+using VoiceNotesAI.ViewModels;
+
+namespace VoiceNotesAI.Pages;
+
+public partial class NoteResultPage : ContentPage
+{
+ public NoteResultPage(NoteResultViewModel viewModel)
+ {
+ InitializeComponent();
+ BindingContext = viewModel;
+
+ CategoryPicker.ItemsSource = NoteResultViewModel.AvailableCategories;
+ }
+}
diff --git a/src/VoiceNotesAI/Pages/RecordingPage.xaml b/src/VoiceNotesAI/Pages/RecordingPage.xaml
new file mode 100644
index 0000000..47c9749
--- /dev/null
+++ b/src/VoiceNotesAI/Pages/RecordingPage.xaml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/VoiceNotesAI/Pages/RecordingPage.xaml.cs b/src/VoiceNotesAI/Pages/RecordingPage.xaml.cs
new file mode 100644
index 0000000..c7a8d0a
--- /dev/null
+++ b/src/VoiceNotesAI/Pages/RecordingPage.xaml.cs
@@ -0,0 +1,12 @@
+using VoiceNotesAI.ViewModels;
+
+namespace VoiceNotesAI.Pages;
+
+public partial class RecordingPage : ContentPage
+{
+ public RecordingPage(RecordingViewModel viewModel)
+ {
+ InitializeComponent();
+ BindingContext = viewModel;
+ }
+}
diff --git a/src/VoiceNotesAI/Platforms/Android/AndroidManifest.xml b/src/VoiceNotesAI/Platforms/Android/AndroidManifest.xml
new file mode 100644
index 0000000..a4b858b
--- /dev/null
+++ b/src/VoiceNotesAI/Platforms/Android/AndroidManifest.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/VoiceNotesAI/Platforms/Android/MainActivity.cs b/src/VoiceNotesAI/Platforms/Android/MainActivity.cs
new file mode 100644
index 0000000..6967bfa
--- /dev/null
+++ b/src/VoiceNotesAI/Platforms/Android/MainActivity.cs
@@ -0,0 +1,12 @@
+using Android.App;
+using Android.Content.PM;
+
+namespace VoiceNotesAI;
+
+[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true,
+ ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation |
+ ConfigChanges.UiMode | ConfigChanges.ScreenLayout |
+ ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
+public class MainActivity : MauiAppCompatActivity
+{
+}
diff --git a/src/VoiceNotesAI/Platforms/Android/MainApplication.cs b/src/VoiceNotesAI/Platforms/Android/MainApplication.cs
new file mode 100644
index 0000000..340a046
--- /dev/null
+++ b/src/VoiceNotesAI/Platforms/Android/MainApplication.cs
@@ -0,0 +1,15 @@
+using Android.App;
+using Android.Runtime;
+
+namespace VoiceNotesAI;
+
+[Application]
+public class MainApplication : MauiApplication
+{
+ public MainApplication(IntPtr handle, JniHandleOwnership ownership)
+ : base(handle, ownership)
+ {
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/src/VoiceNotesAI/Resources/Styles/Colors.xaml b/src/VoiceNotesAI/Resources/Styles/Colors.xaml
new file mode 100644
index 0000000..8e1f03a
--- /dev/null
+++ b/src/VoiceNotesAI/Resources/Styles/Colors.xaml
@@ -0,0 +1,25 @@
+
+
+
+
+ #6750A4
+ #D0BCFF
+ #625B71
+ #CCC2DC
+ #7D5260
+ #EFB8C8
+
+ #FFFFFF
+ #000000
+
+ #E1E1E1
+ #C8C8C8
+ #ACACAC
+ #919191
+ #6E6E6E
+ #404040
+ #212121
+ #141414
+
+
diff --git a/src/VoiceNotesAI/Resources/Styles/Styles.xaml b/src/VoiceNotesAI/Resources/Styles/Styles.xaml
new file mode 100644
index 0000000..f2e6477
--- /dev/null
+++ b/src/VoiceNotesAI/Resources/Styles/Styles.xaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/VoiceNotesAI/Services/AIService.cs b/src/VoiceNotesAI/Services/AIService.cs
new file mode 100644
index 0000000..e5f6447
--- /dev/null
+++ b/src/VoiceNotesAI/Services/AIService.cs
@@ -0,0 +1,63 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using VoiceNotesAI.Helpers;
+using VoiceNotesAI.Models;
+
+namespace VoiceNotesAI.Services;
+
+public class AIService : IAIService
+{
+ private const string ChatEndpoint = "https://api.openai.com/v1/chat/completions";
+
+ private readonly HttpClient _httpClient;
+ private readonly string _apiKey;
+ private readonly string _model;
+
+ public AIService(HttpClient httpClient, string apiKey, string model = "gpt-4o")
+ {
+ _httpClient = httpClient;
+ _apiKey = apiKey;
+ _model = model;
+ }
+
+ public async Task InterpretNoteAsync(string transcribedText)
+ {
+ var prompt = PromptTemplates.BuildNotePrompt(transcribedText);
+
+ var requestBody = new
+ {
+ model = _model,
+ messages = new[]
+ {
+ new { role = "user", content = prompt }
+ },
+ temperature = 0.3
+ };
+
+ var json = JsonSerializer.Serialize(requestBody);
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, ChatEndpoint);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
+ request.Content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await _httpClient.SendAsync(request);
+ response.EnsureSuccessStatusCode();
+
+ var responseJson = await response.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(responseJson);
+
+ var messageContent = doc.RootElement
+ .GetProperty("choices")[0]
+ .GetProperty("message")
+ .GetProperty("content")
+ .GetString();
+
+ if (string.IsNullOrWhiteSpace(messageContent))
+ throw new InvalidOperationException("AI returned an empty response.");
+
+ var result = JsonSerializer.Deserialize(messageContent);
+
+ return result ?? throw new InvalidOperationException("Failed to deserialize AI response.");
+ }
+}
diff --git a/src/VoiceNotesAI/Services/AudioService.cs b/src/VoiceNotesAI/Services/AudioService.cs
new file mode 100644
index 0000000..602fdbf
--- /dev/null
+++ b/src/VoiceNotesAI/Services/AudioService.cs
@@ -0,0 +1,41 @@
+using Plugin.Maui.Audio;
+
+namespace VoiceNotesAI.Services;
+
+public class AudioService : IAudioService
+{
+ private readonly IAudioManager _audioManager;
+ private IAudioRecorder? _recorder;
+ private string _currentFilePath = string.Empty;
+
+ public AudioService(IAudioManager audioManager)
+ {
+ _audioManager = audioManager;
+ }
+
+ public bool IsRecording => _recorder?.IsRecording ?? false;
+
+ public async Task StartRecordingAsync()
+ {
+ var fileName = $"{Guid.NewGuid()}.wav";
+ var audioDir = Path.Combine(FileSystem.AppDataDirectory, "audio");
+ Directory.CreateDirectory(audioDir);
+ _currentFilePath = Path.Combine(audioDir, fileName);
+
+ _recorder = _audioManager.CreateRecorder();
+ await _recorder.StartAsync(_currentFilePath);
+
+ return _currentFilePath;
+ }
+
+ public async Task StopRecordingAsync()
+ {
+ if (_recorder is null || !_recorder.IsRecording)
+ return string.Empty;
+
+ var source = await _recorder.StopAsync();
+ source.Dispose();
+
+ return _currentFilePath;
+ }
+}
diff --git a/src/VoiceNotesAI/Services/IAIService.cs b/src/VoiceNotesAI/Services/IAIService.cs
new file mode 100644
index 0000000..0aeba91
--- /dev/null
+++ b/src/VoiceNotesAI/Services/IAIService.cs
@@ -0,0 +1,8 @@
+using VoiceNotesAI.Models;
+
+namespace VoiceNotesAI.Services;
+
+public interface IAIService
+{
+ Task InterpretNoteAsync(string transcribedText);
+}
diff --git a/src/VoiceNotesAI/Services/IAudioService.cs b/src/VoiceNotesAI/Services/IAudioService.cs
new file mode 100644
index 0000000..155482e
--- /dev/null
+++ b/src/VoiceNotesAI/Services/IAudioService.cs
@@ -0,0 +1,8 @@
+namespace VoiceNotesAI.Services;
+
+public interface IAudioService
+{
+ bool IsRecording { get; }
+ Task StartRecordingAsync();
+ Task StopRecordingAsync();
+}
diff --git a/src/VoiceNotesAI/Services/INoteRepository.cs b/src/VoiceNotesAI/Services/INoteRepository.cs
new file mode 100644
index 0000000..655f1ca
--- /dev/null
+++ b/src/VoiceNotesAI/Services/INoteRepository.cs
@@ -0,0 +1,12 @@
+using VoiceNotesAI.Models;
+
+namespace VoiceNotesAI.Services;
+
+public interface INoteRepository
+{
+ Task> GetAllAsync();
+ Task GetByIdAsync(int id);
+ Task SaveAsync(Note note);
+ Task DeleteAsync(int id);
+ Task> GetByCategoryAsync(string category);
+}
diff --git a/src/VoiceNotesAI/Services/ISpeechToTextService.cs b/src/VoiceNotesAI/Services/ISpeechToTextService.cs
new file mode 100644
index 0000000..4cb1aee
--- /dev/null
+++ b/src/VoiceNotesAI/Services/ISpeechToTextService.cs
@@ -0,0 +1,6 @@
+namespace VoiceNotesAI.Services;
+
+public interface ISpeechToTextService
+{
+ Task TranscribeAsync(string audioFilePath);
+}
diff --git a/src/VoiceNotesAI/Services/NoteRepository.cs b/src/VoiceNotesAI/Services/NoteRepository.cs
new file mode 100644
index 0000000..ab20fd8
--- /dev/null
+++ b/src/VoiceNotesAI/Services/NoteRepository.cs
@@ -0,0 +1,57 @@
+using VoiceNotesAI.Data;
+using VoiceNotesAI.Models;
+
+namespace VoiceNotesAI.Services;
+
+public class NoteRepository : INoteRepository
+{
+ private readonly AppDatabase _database;
+
+ public NoteRepository(AppDatabase database)
+ {
+ _database = database;
+ }
+
+ public async Task> GetAllAsync()
+ {
+ return await _database.Connection
+ .Table()
+ .OrderByDescending(n => n.CreatedAt)
+ .ToListAsync();
+ }
+
+ public async Task GetByIdAsync(int id)
+ {
+ return await _database.Connection
+ .Table()
+ .Where(n => n.Id == id)
+ .FirstOrDefaultAsync();
+ }
+
+ public async Task SaveAsync(Note note)
+ {
+ note.UpdatedAt = DateTime.UtcNow;
+
+ if (note.Id != 0)
+ {
+ return await _database.Connection.UpdateAsync(note);
+ }
+
+ note.CreatedAt = DateTime.UtcNow;
+ return await _database.Connection.InsertAsync(note);
+ }
+
+ public async Task DeleteAsync(int id)
+ {
+ return await _database.Connection.DeleteAsync(id);
+ }
+
+ public async Task> GetByCategoryAsync(string category)
+ {
+ return await _database.Connection
+ .Table()
+ .Where(n => n.Category == category)
+ .OrderByDescending(n => n.CreatedAt)
+ .ToListAsync();
+ }
+}
diff --git a/src/VoiceNotesAI/Services/SpeechToTextService.cs b/src/VoiceNotesAI/Services/SpeechToTextService.cs
new file mode 100644
index 0000000..a8a04eb
--- /dev/null
+++ b/src/VoiceNotesAI/Services/SpeechToTextService.cs
@@ -0,0 +1,47 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+
+namespace VoiceNotesAI.Services;
+
+public class SpeechToTextService : ISpeechToTextService
+{
+ private const string WhisperEndpoint = "https://api.openai.com/v1/audio/transcriptions";
+
+ private readonly HttpClient _httpClient;
+ private readonly string _apiKey;
+ private readonly string _model;
+
+ public SpeechToTextService(HttpClient httpClient, string apiKey, string model = "whisper-1")
+ {
+ _httpClient = httpClient;
+ _apiKey = apiKey;
+ _model = model;
+ }
+
+ public async Task TranscribeAsync(string audioFilePath)
+ {
+ if (!File.Exists(audioFilePath))
+ throw new FileNotFoundException("Audio file not found.", audioFilePath);
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, WhisperEndpoint);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey);
+
+ using var content = new MultipartFormDataContent();
+ var audioBytes = await File.ReadAllBytesAsync(audioFilePath);
+ var audioContent = new ByteArrayContent(audioBytes);
+ audioContent.Headers.ContentType = new MediaTypeHeaderValue("audio/wav");
+ content.Add(audioContent, "file", Path.GetFileName(audioFilePath));
+ content.Add(new StringContent(_model), "model");
+ content.Add(new StringContent("pt"), "language");
+
+ request.Content = content;
+
+ var response = await _httpClient.SendAsync(request);
+ response.EnsureSuccessStatusCode();
+
+ var json = await response.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(json);
+
+ return doc.RootElement.GetProperty("text").GetString() ?? string.Empty;
+ }
+}
diff --git a/src/VoiceNotesAI/ViewModels/NoteDetailViewModel.cs b/src/VoiceNotesAI/ViewModels/NoteDetailViewModel.cs
new file mode 100644
index 0000000..4364e49
--- /dev/null
+++ b/src/VoiceNotesAI/ViewModels/NoteDetailViewModel.cs
@@ -0,0 +1,107 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using VoiceNotesAI.Models;
+using VoiceNotesAI.Services;
+
+namespace VoiceNotesAI.ViewModels;
+
+public partial class NoteDetailViewModel : ObservableObject, IQueryAttributable
+{
+ private readonly INoteRepository _noteRepository;
+
+ public NoteDetailViewModel(INoteRepository noteRepository)
+ {
+ _noteRepository = noteRepository;
+ }
+
+ [ObservableProperty]
+ private int _noteId;
+
+ [ObservableProperty]
+ private string _title = string.Empty;
+
+ [ObservableProperty]
+ private string _description = string.Empty;
+
+ [ObservableProperty]
+ private string _category = string.Empty;
+
+ [ObservableProperty]
+ private string _audioFilePath = string.Empty;
+
+ [ObservableProperty]
+ private DateTime _createdAt;
+
+ [ObservableProperty]
+ private bool _isSaving;
+
+ public static readonly string[] AvailableCategories =
+ [
+ "Tarefas", "Ideias", "Lembretes", "Trabalho", "Pessoal", "Outros"
+ ];
+
+ public void ApplyQueryAttributes(IDictionary query)
+ {
+ if (query.TryGetValue("Note", out var noteObj) && noteObj is Note note)
+ {
+ NoteId = note.Id;
+ Title = note.Title;
+ Description = note.Description;
+ Category = note.Category;
+ AudioFilePath = note.AudioFilePath;
+ CreatedAt = note.CreatedAt;
+ }
+ }
+
+ [RelayCommand]
+ private async Task SaveAsync()
+ {
+ if (string.IsNullOrWhiteSpace(Title))
+ {
+ await Shell.Current.DisplayAlert("Erro", "O título é obrigatório.", "OK");
+ return;
+ }
+
+ IsSaving = true;
+
+ try
+ {
+ var note = new Note
+ {
+ Id = NoteId,
+ Title = Title,
+ Description = Description,
+ Category = Category,
+ AudioFilePath = AudioFilePath,
+ CreatedAt = CreatedAt
+ };
+
+ await _noteRepository.SaveAsync(note);
+ await Shell.Current.GoToAsync("..");
+ }
+ finally
+ {
+ IsSaving = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task DeleteAsync()
+ {
+ bool confirm = await Shell.Current.DisplayAlert(
+ "Excluir nota",
+ $"Deseja excluir \"{Title}\"?",
+ "Sim", "Não");
+
+ if (!confirm) return;
+
+ await _noteRepository.DeleteAsync(NoteId);
+ await Shell.Current.GoToAsync("..");
+ }
+
+ [RelayCommand]
+ private async Task GoBackAsync()
+ {
+ await Shell.Current.GoToAsync("..");
+ }
+}
diff --git a/src/VoiceNotesAI/ViewModels/NoteListViewModel.cs b/src/VoiceNotesAI/ViewModels/NoteListViewModel.cs
new file mode 100644
index 0000000..658d913
--- /dev/null
+++ b/src/VoiceNotesAI/ViewModels/NoteListViewModel.cs
@@ -0,0 +1,87 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using VoiceNotesAI.Models;
+using VoiceNotesAI.Services;
+
+namespace VoiceNotesAI.ViewModels;
+
+public partial class NoteListViewModel : ObservableObject
+{
+ private readonly INoteRepository _noteRepository;
+
+ public NoteListViewModel(INoteRepository noteRepository)
+ {
+ _noteRepository = noteRepository;
+ }
+
+ [ObservableProperty]
+ private ObservableCollection _notes = [];
+
+ [ObservableProperty]
+ private string _selectedCategory = string.Empty;
+
+ [ObservableProperty]
+ private bool _isLoading;
+
+ [ObservableProperty]
+ private bool _isEmpty;
+
+ [RelayCommand]
+ private async Task LoadNotesAsync()
+ {
+ IsLoading = true;
+
+ try
+ {
+ List noteList;
+
+ if (string.IsNullOrEmpty(SelectedCategory))
+ noteList = await _noteRepository.GetAllAsync();
+ else
+ noteList = await _noteRepository.GetByCategoryAsync(SelectedCategory);
+
+ Notes = new ObservableCollection(noteList);
+ IsEmpty = Notes.Count == 0;
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task GoToRecordingAsync()
+ {
+ await Shell.Current.GoToAsync("RecordingPage");
+ }
+
+ [RelayCommand]
+ private async Task GoToDetailAsync(Note note)
+ {
+ var parameters = new Dictionary { { "Note", note } };
+ await Shell.Current.GoToAsync("NoteDetailPage", parameters);
+ }
+
+ [RelayCommand]
+ private async Task DeleteNoteAsync(Note note)
+ {
+ bool confirm = await Shell.Current.DisplayAlert(
+ "Excluir nota",
+ $"Deseja excluir \"{note.Title}\"?",
+ "Sim", "Não");
+
+ if (!confirm) return;
+
+ await _noteRepository.DeleteAsync(note.Id);
+ Notes.Remove(note);
+ IsEmpty = Notes.Count == 0;
+ }
+
+ [RelayCommand]
+ private async Task FilterByCategoryAsync(string category)
+ {
+ SelectedCategory = category;
+ await LoadNotesAsync();
+ }
+}
diff --git a/src/VoiceNotesAI/ViewModels/NoteResultViewModel.cs b/src/VoiceNotesAI/ViewModels/NoteResultViewModel.cs
new file mode 100644
index 0000000..3347792
--- /dev/null
+++ b/src/VoiceNotesAI/ViewModels/NoteResultViewModel.cs
@@ -0,0 +1,91 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using VoiceNotesAI.Models;
+using VoiceNotesAI.Services;
+
+namespace VoiceNotesAI.ViewModels;
+
+public partial class NoteResultViewModel : ObservableObject, IQueryAttributable
+{
+ private readonly INoteRepository _noteRepository;
+
+ public NoteResultViewModel(INoteRepository noteRepository)
+ {
+ _noteRepository = noteRepository;
+ }
+
+ [ObservableProperty]
+ private string _title = string.Empty;
+
+ [ObservableProperty]
+ private string _description = string.Empty;
+
+ [ObservableProperty]
+ private string _category = string.Empty;
+
+ [ObservableProperty]
+ private string _audioFilePath = string.Empty;
+
+ [ObservableProperty]
+ private string _transcribedText = string.Empty;
+
+ [ObservableProperty]
+ private bool _isSaving;
+
+ public static readonly string[] AvailableCategories =
+ [
+ "Tarefas", "Ideias", "Lembretes", "Trabalho", "Pessoal", "Outros"
+ ];
+
+ public void ApplyQueryAttributes(IDictionary query)
+ {
+ if (query.TryGetValue("NoteResult", out var noteResultObj) && noteResultObj is NoteResult result)
+ {
+ Title = result.Title;
+ Description = result.Description;
+ Category = result.Category;
+ }
+
+ if (query.TryGetValue("AudioFilePath", out var audioObj) && audioObj is string audioPath)
+ AudioFilePath = audioPath;
+
+ if (query.TryGetValue("TranscribedText", out var textObj) && textObj is string text)
+ TranscribedText = text;
+ }
+
+ [RelayCommand]
+ private async Task SaveNoteAsync()
+ {
+ if (string.IsNullOrWhiteSpace(Title))
+ {
+ await Shell.Current.DisplayAlert("Erro", "O título é obrigatório.", "OK");
+ return;
+ }
+
+ IsSaving = true;
+
+ try
+ {
+ var note = new Note
+ {
+ Title = Title,
+ Description = Description,
+ Category = Category,
+ AudioFilePath = AudioFilePath
+ };
+
+ await _noteRepository.SaveAsync(note);
+ await Shell.Current.GoToAsync("../..");
+ }
+ finally
+ {
+ IsSaving = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task CancelAsync()
+ {
+ await Shell.Current.GoToAsync("..");
+ }
+}
diff --git a/src/VoiceNotesAI/ViewModels/RecordingViewModel.cs b/src/VoiceNotesAI/ViewModels/RecordingViewModel.cs
new file mode 100644
index 0000000..afd198f
--- /dev/null
+++ b/src/VoiceNotesAI/ViewModels/RecordingViewModel.cs
@@ -0,0 +1,106 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using VoiceNotesAI.Models;
+using VoiceNotesAI.Services;
+
+namespace VoiceNotesAI.ViewModels;
+
+public partial class RecordingViewModel : ObservableObject
+{
+ private readonly IAudioService _audioService;
+ private readonly ISpeechToTextService _speechToTextService;
+ private readonly IAIService _aiService;
+
+ public RecordingViewModel(
+ IAudioService audioService,
+ ISpeechToTextService speechToTextService,
+ IAIService aiService)
+ {
+ _audioService = audioService;
+ _speechToTextService = speechToTextService;
+ _aiService = aiService;
+ }
+
+ [ObservableProperty]
+ private bool _isRecording;
+
+ [ObservableProperty]
+ private bool _isProcessing;
+
+ [ObservableProperty]
+ private string _statusMessage = "Toque para gravar";
+
+ [ObservableProperty]
+ private string _audioFilePath = string.Empty;
+
+ [ObservableProperty]
+ private string _transcribedText = string.Empty;
+
+ [RelayCommand]
+ private async Task ToggleRecordingAsync()
+ {
+ if (IsRecording)
+ await StopRecordingAsync();
+ else
+ await StartRecordingAsync();
+ }
+
+ private async Task StartRecordingAsync()
+ {
+ var status = await Permissions.RequestAsync();
+ if (status != PermissionStatus.Granted)
+ {
+ StatusMessage = "Permissão de microfone negada";
+ return;
+ }
+
+ AudioFilePath = await _audioService.StartRecordingAsync();
+ IsRecording = true;
+ StatusMessage = "Gravando... Toque para parar";
+ }
+
+ private async Task StopRecordingAsync()
+ {
+ AudioFilePath = await _audioService.StopRecordingAsync();
+ IsRecording = false;
+ StatusMessage = "Gravação finalizada";
+ }
+
+ [RelayCommand]
+ private async Task ProcessAudioAsync()
+ {
+ if (string.IsNullOrEmpty(AudioFilePath))
+ {
+ StatusMessage = "Nenhuma gravação encontrada";
+ return;
+ }
+
+ IsProcessing = true;
+ StatusMessage = "Transcrevendo áudio...";
+
+ try
+ {
+ TranscribedText = await _speechToTextService.TranscribeAsync(AudioFilePath);
+
+ StatusMessage = "Interpretando com IA...";
+ var result = await _aiService.InterpretNoteAsync(TranscribedText);
+
+ var parameters = new Dictionary
+ {
+ { "NoteResult", result },
+ { "AudioFilePath", AudioFilePath },
+ { "TranscribedText", TranscribedText }
+ };
+
+ await Shell.Current.GoToAsync("NoteResultPage", parameters);
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Erro: {ex.Message}";
+ }
+ finally
+ {
+ IsProcessing = false;
+ }
+ }
+}
diff --git a/src/VoiceNotesAI/VoiceNotesAI.csproj b/src/VoiceNotesAI/VoiceNotesAI.csproj
new file mode 100644
index 0000000..2f99ffd
--- /dev/null
+++ b/src/VoiceNotesAI/VoiceNotesAI.csproj
@@ -0,0 +1,37 @@
+
+
+
+ net8.0-android
+ Exe
+ VoiceNotesAI
+ true
+ true
+ enable
+ enable
+
+ VoiceNotes AI
+ com.voicenotesai.app
+ 1.0
+ 1
+
+ 21.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/VoiceNotesAI/appsettings.example.json b/src/VoiceNotesAI/appsettings.example.json
new file mode 100644
index 0000000..14bfb0f
--- /dev/null
+++ b/src/VoiceNotesAI/appsettings.example.json
@@ -0,0 +1,7 @@
+{
+ "OpenAI": {
+ "ApiKey": "YOUR_API_KEY_HERE",
+ "Model": "gpt-4o",
+ "WhisperModel": "whisper-1"
+ }
+}
diff --git a/tests/VoiceNotesAI.Tests/Helpers/PromptTemplatesTests.cs b/tests/VoiceNotesAI.Tests/Helpers/PromptTemplatesTests.cs
new file mode 100644
index 0000000..b7b7944
--- /dev/null
+++ b/tests/VoiceNotesAI.Tests/Helpers/PromptTemplatesTests.cs
@@ -0,0 +1,55 @@
+using VoiceNotesAI.Helpers;
+
+namespace VoiceNotesAI.Tests.Helpers;
+
+public class PromptTemplatesTests
+{
+ [Fact]
+ public void BuildNotePrompt_ShouldReplaceTranscribedTextPlaceholder()
+ {
+ var transcribed = "Preciso comprar pão amanhã cedo";
+
+ var prompt = PromptTemplates.BuildNotePrompt(transcribed);
+
+ Assert.Contains(transcribed, prompt);
+ Assert.DoesNotContain("{{TRANSCRIBED_TEXT}}", prompt);
+ }
+
+ [Fact]
+ public void BuildNotePrompt_ShouldContainJsonStructureInstruction()
+ {
+ var prompt = PromptTemplates.BuildNotePrompt("teste");
+
+ Assert.Contains("title", prompt);
+ Assert.Contains("description", prompt);
+ Assert.Contains("category", prompt);
+ }
+
+ [Fact]
+ public void BuildNotePrompt_ShouldContainAllValidCategories()
+ {
+ var prompt = PromptTemplates.BuildNotePrompt("teste");
+
+ Assert.Contains("Tarefas", prompt);
+ Assert.Contains("Ideias", prompt);
+ Assert.Contains("Lembretes", prompt);
+ Assert.Contains("Trabalho", prompt);
+ Assert.Contains("Pessoal", prompt);
+ Assert.Contains("Outros", prompt);
+ }
+
+ [Fact]
+ public void BuildNotePrompt_WithEmptyText_ShouldStillBuildPrompt()
+ {
+ var prompt = PromptTemplates.BuildNotePrompt(string.Empty);
+
+ Assert.DoesNotContain("{{TRANSCRIBED_TEXT}}", prompt);
+ Assert.Contains("note-taking assistant", prompt);
+ }
+
+ [Fact]
+ public void NoteInterpretation_ShouldContainPlaceholder()
+ {
+ Assert.Contains("{{TRANSCRIBED_TEXT}}", PromptTemplates.NoteInterpretation);
+ }
+}
diff --git a/tests/VoiceNotesAI.Tests/Models/NoteResultTests.cs b/tests/VoiceNotesAI.Tests/Models/NoteResultTests.cs
new file mode 100644
index 0000000..a8c0a9d
--- /dev/null
+++ b/tests/VoiceNotesAI.Tests/Models/NoteResultTests.cs
@@ -0,0 +1,67 @@
+using System.Text.Json;
+using VoiceNotesAI.Models;
+
+namespace VoiceNotesAI.Tests.Models;
+
+public class NoteResultTests
+{
+ [Fact]
+ public void NoteResult_DefaultValues_ShouldBeEmpty()
+ {
+ var result = new NoteResult();
+
+ Assert.Equal(string.Empty, result.Title);
+ Assert.Equal(string.Empty, result.Description);
+ Assert.Equal(string.Empty, result.Category);
+ }
+
+ [Fact]
+ public void NoteResult_Deserialize_ShouldMapJsonProperties()
+ {
+ var json = """
+ {
+ "title": "Comprar leite",
+ "description": "Lembrar de comprar leite no mercado depois do trabalho",
+ "category": "Tarefas"
+ }
+ """;
+
+ var result = JsonSerializer.Deserialize(json);
+
+ Assert.NotNull(result);
+ Assert.Equal("Comprar leite", result.Title);
+ Assert.Equal("Lembrar de comprar leite no mercado depois do trabalho", result.Description);
+ Assert.Equal("Tarefas", result.Category);
+ }
+
+ [Fact]
+ public void NoteResult_Serialize_ShouldUseJsonPropertyNames()
+ {
+ var result = new NoteResult
+ {
+ Title = "Ideia de app",
+ Description = "Criar um app de notas por voz com IA",
+ Category = "Ideias"
+ };
+
+ var json = JsonSerializer.Serialize(result);
+
+ Assert.Contains("\"title\":", json);
+ Assert.Contains("\"description\":", json);
+ Assert.Contains("\"category\":", json);
+ Assert.DoesNotContain("\"Title\":", json);
+ }
+
+ [Fact]
+ public void NoteResult_Deserialize_WithMissingFields_ShouldUseDefaults()
+ {
+ var json = """{ "title": "Apenas título" }""";
+
+ var result = JsonSerializer.Deserialize(json);
+
+ Assert.NotNull(result);
+ Assert.Equal("Apenas título", result.Title);
+ Assert.Equal(string.Empty, result.Description);
+ Assert.Equal(string.Empty, result.Category);
+ }
+}
diff --git a/tests/VoiceNotesAI.Tests/Models/NoteTests.cs b/tests/VoiceNotesAI.Tests/Models/NoteTests.cs
new file mode 100644
index 0000000..e8b8901
--- /dev/null
+++ b/tests/VoiceNotesAI.Tests/Models/NoteTests.cs
@@ -0,0 +1,45 @@
+using VoiceNotesAI.Models;
+
+namespace VoiceNotesAI.Tests.Models;
+
+public class NoteTests
+{
+ [Fact]
+ public void Note_DefaultValues_ShouldBeInitialized()
+ {
+ var note = new Note();
+
+ Assert.Equal(0, note.Id);
+ Assert.Equal(string.Empty, note.Title);
+ Assert.Equal(string.Empty, note.Description);
+ Assert.Equal(string.Empty, note.Category);
+ Assert.Equal(string.Empty, note.AudioFilePath);
+ Assert.True(note.CreatedAt <= DateTime.UtcNow);
+ Assert.True(note.UpdatedAt <= DateTime.UtcNow);
+ }
+
+ [Fact]
+ public void Note_SetProperties_ShouldRetainValues()
+ {
+ var now = DateTime.UtcNow;
+
+ var note = new Note
+ {
+ Id = 42,
+ Title = "Reunião amanhã",
+ Description = "Lembrar de preparar a apresentação",
+ Category = "Trabalho",
+ AudioFilePath = "/data/audio/note_42.wav",
+ CreatedAt = now,
+ UpdatedAt = now
+ };
+
+ Assert.Equal(42, note.Id);
+ Assert.Equal("Reunião amanhã", note.Title);
+ Assert.Equal("Lembrar de preparar a apresentação", note.Description);
+ Assert.Equal("Trabalho", note.Category);
+ Assert.Equal("/data/audio/note_42.wav", note.AudioFilePath);
+ Assert.Equal(now, note.CreatedAt);
+ Assert.Equal(now, note.UpdatedAt);
+ }
+}
diff --git a/tests/VoiceNotesAI.Tests/Services/AIServiceTests.cs b/tests/VoiceNotesAI.Tests/Services/AIServiceTests.cs
new file mode 100644
index 0000000..d0de2af
--- /dev/null
+++ b/tests/VoiceNotesAI.Tests/Services/AIServiceTests.cs
@@ -0,0 +1,191 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using Moq;
+using Moq.Protected;
+using VoiceNotesAI.Services;
+
+namespace VoiceNotesAI.Tests.Services;
+
+public class AIServiceTests
+{
+ private static HttpClient CreateMockHttpClient(HttpStatusCode statusCode, string responseContent)
+ {
+ var handler = new Mock();
+ handler
+ .Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = statusCode,
+ Content = new StringContent(responseContent, Encoding.UTF8, "application/json")
+ });
+
+ return new HttpClient(handler.Object);
+ }
+
+ private static string BuildOpenAIResponse(string title, string description, string category)
+ {
+ var noteResultJson = JsonSerializer.Serialize(new
+ {
+ title,
+ description,
+ category
+ });
+
+ return JsonSerializer.Serialize(new
+ {
+ choices = new[]
+ {
+ new
+ {
+ message = new
+ {
+ content = noteResultJson
+ }
+ }
+ }
+ });
+ }
+
+ [Fact]
+ public async Task InterpretNoteAsync_ValidResponse_ShouldReturnNoteResult()
+ {
+ var responseJson = BuildOpenAIResponse(
+ "Comprar leite",
+ "Lembrar de comprar leite no mercado",
+ "Tarefas");
+
+ var httpClient = CreateMockHttpClient(HttpStatusCode.OK, responseJson);
+ var service = new AIService(httpClient, "test-key", "gpt-4o");
+
+ var result = await service.InterpretNoteAsync("preciso comprar leite");
+
+ Assert.Equal("Comprar leite", result.Title);
+ Assert.Equal("Lembrar de comprar leite no mercado", result.Description);
+ Assert.Equal("Tarefas", result.Category);
+ }
+
+ [Fact]
+ public async Task InterpretNoteAsync_ShouldSendAuthorizationHeader()
+ {
+ var responseJson = BuildOpenAIResponse("Título", "Desc", "Ideias");
+
+ HttpRequestMessage? capturedRequest = null;
+ var handler = new Mock();
+ handler
+ .Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .Callback((req, _) => capturedRequest = req)
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.OK,
+ Content = new StringContent(responseJson, Encoding.UTF8, "application/json")
+ });
+
+ var httpClient = new HttpClient(handler.Object);
+ var service = new AIService(httpClient, "my-secret-key", "gpt-4o");
+
+ await service.InterpretNoteAsync("texto teste");
+
+ Assert.NotNull(capturedRequest);
+ Assert.Equal("Bearer", capturedRequest.Headers.Authorization?.Scheme);
+ Assert.Equal("my-secret-key", capturedRequest.Headers.Authorization?.Parameter);
+ }
+
+ [Fact]
+ public async Task InterpretNoteAsync_ShouldSendCorrectModel()
+ {
+ var responseJson = BuildOpenAIResponse("T", "D", "Outros");
+
+ string? capturedBody = null;
+ var handler = new Mock();
+ handler
+ .Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .Callback(async (req, _) =>
+ {
+ capturedBody = await req.Content!.ReadAsStringAsync();
+ })
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.OK,
+ Content = new StringContent(responseJson, Encoding.UTF8, "application/json")
+ });
+
+ var httpClient = new HttpClient(handler.Object);
+ var service = new AIService(httpClient, "key", "gpt-4o-mini");
+
+ await service.InterpretNoteAsync("teste");
+
+ Assert.NotNull(capturedBody);
+ Assert.Contains("gpt-4o-mini", capturedBody);
+ }
+
+ [Fact]
+ public async Task InterpretNoteAsync_ApiError_ShouldThrowHttpRequestException()
+ {
+ var httpClient = CreateMockHttpClient(HttpStatusCode.Unauthorized, "Unauthorized");
+ var service = new AIService(httpClient, "invalid-key");
+
+ await Assert.ThrowsAsync(
+ () => service.InterpretNoteAsync("texto"));
+ }
+
+ [Fact]
+ public async Task InterpretNoteAsync_EmptyContent_ShouldThrowInvalidOperationException()
+ {
+ var responseJson = JsonSerializer.Serialize(new
+ {
+ choices = new[]
+ {
+ new
+ {
+ message = new
+ {
+ content = (string?)null
+ }
+ }
+ }
+ });
+
+ var httpClient = CreateMockHttpClient(HttpStatusCode.OK, responseJson);
+ var service = new AIService(httpClient, "key");
+
+ await Assert.ThrowsAsync(
+ () => service.InterpretNoteAsync("texto"));
+ }
+
+ [Fact]
+ public async Task InterpretNoteAsync_InvalidJson_ShouldThrowJsonException()
+ {
+ var responseJson = JsonSerializer.Serialize(new
+ {
+ choices = new[]
+ {
+ new
+ {
+ message = new
+ {
+ content = "this is not valid JSON"
+ }
+ }
+ }
+ });
+
+ var httpClient = CreateMockHttpClient(HttpStatusCode.OK, responseJson);
+ var service = new AIService(httpClient, "key");
+
+ await Assert.ThrowsAsync(
+ () => service.InterpretNoteAsync("texto"));
+ }
+}
diff --git a/tests/VoiceNotesAI.Tests/Services/NoteRepositoryTests.cs b/tests/VoiceNotesAI.Tests/Services/NoteRepositoryTests.cs
new file mode 100644
index 0000000..4853188
--- /dev/null
+++ b/tests/VoiceNotesAI.Tests/Services/NoteRepositoryTests.cs
@@ -0,0 +1,154 @@
+using VoiceNotesAI.Data;
+using VoiceNotesAI.Models;
+using VoiceNotesAI.Services;
+
+namespace VoiceNotesAI.Tests.Services;
+
+public class NoteRepositoryTests : IAsyncLifetime
+{
+ private AppDatabase _database = null!;
+ private NoteRepository _repository = null!;
+ private string _dbPath = null!;
+
+ public async Task InitializeAsync()
+ {
+ _dbPath = Path.Combine(Path.GetTempPath(), $"voicenotes_test_{Guid.NewGuid()}.db3");
+ _database = new AppDatabase(_dbPath);
+ await _database.InitializeAsync();
+ _repository = new NoteRepository(_database);
+ }
+
+ public Task DisposeAsync()
+ {
+ if (File.Exists(_dbPath))
+ File.Delete(_dbPath);
+ return Task.CompletedTask;
+ }
+
+ [Fact]
+ public async Task SaveAsync_NewNote_ShouldInsertAndReturnPositiveId()
+ {
+ var note = new Note
+ {
+ Title = "Nota teste",
+ Description = "Descrição de teste",
+ Category = "Tarefas"
+ };
+
+ var result = await _repository.SaveAsync(note);
+
+ Assert.True(result > 0);
+ }
+
+ [Fact]
+ public async Task SaveAsync_NewNote_ShouldSetCreatedAt()
+ {
+ var before = DateTime.UtcNow.AddSeconds(-1);
+
+ var note = new Note
+ {
+ Title = "Nota com data",
+ Description = "Teste",
+ Category = "Ideias"
+ };
+
+ await _repository.SaveAsync(note);
+
+ Assert.True(note.CreatedAt >= before);
+ Assert.True(note.UpdatedAt >= before);
+ }
+
+ [Fact]
+ public async Task SaveAsync_ExistingNote_ShouldUpdate()
+ {
+ var note = new Note { Title = "Original", Description = "Desc", Category = "Tarefas" };
+ await _repository.SaveAsync(note);
+
+ var saved = (await _repository.GetAllAsync()).First();
+ saved.Title = "Atualizado";
+ await _repository.SaveAsync(saved);
+
+ var updated = await _repository.GetByIdAsync(saved.Id);
+
+ Assert.NotNull(updated);
+ Assert.Equal("Atualizado", updated.Title);
+ }
+
+ [Fact]
+ public async Task GetAllAsync_ShouldReturnAllNotes_OrderedByCreatedAtDesc()
+ {
+ await _repository.SaveAsync(new Note { Title = "Primeira", Category = "Tarefas" });
+ await Task.Delay(50);
+ await _repository.SaveAsync(new Note { Title = "Segunda", Category = "Ideias" });
+ await Task.Delay(50);
+ await _repository.SaveAsync(new Note { Title = "Terceira", Category = "Lembretes" });
+
+ var notes = await _repository.GetAllAsync();
+
+ Assert.Equal(3, notes.Count);
+ Assert.Equal("Terceira", notes[0].Title);
+ Assert.Equal("Segunda", notes[1].Title);
+ Assert.Equal("Primeira", notes[2].Title);
+ }
+
+ [Fact]
+ public async Task GetByIdAsync_ExistingId_ShouldReturnNote()
+ {
+ var note = new Note { Title = "Buscar por ID", Category = "Pessoal" };
+ await _repository.SaveAsync(note);
+
+ var allNotes = await _repository.GetAllAsync();
+ var found = await _repository.GetByIdAsync(allNotes.First().Id);
+
+ Assert.NotNull(found);
+ Assert.Equal("Buscar por ID", found.Title);
+ }
+
+ [Fact]
+ public async Task GetByIdAsync_NonExistingId_ShouldReturnNull()
+ {
+ var found = await _repository.GetByIdAsync(99999);
+
+ Assert.Null(found);
+ }
+
+ [Fact]
+ public async Task DeleteAsync_ShouldRemoveNote()
+ {
+ var note = new Note { Title = "Para excluir", Category = "Outros" };
+ await _repository.SaveAsync(note);
+
+ var allNotes = await _repository.GetAllAsync();
+ Assert.Single(allNotes);
+
+ await _repository.DeleteAsync(allNotes.First().Id);
+
+ var remaining = await _repository.GetAllAsync();
+ Assert.Empty(remaining);
+ }
+
+ [Fact]
+ public async Task GetByCategoryAsync_ShouldReturnOnlyMatchingCategory()
+ {
+ await _repository.SaveAsync(new Note { Title = "Tarefa 1", Category = "Tarefas" });
+ await _repository.SaveAsync(new Note { Title = "Ideia 1", Category = "Ideias" });
+ await _repository.SaveAsync(new Note { Title = "Tarefa 2", Category = "Tarefas" });
+
+ var tarefas = await _repository.GetByCategoryAsync("Tarefas");
+ var ideias = await _repository.GetByCategoryAsync("Ideias");
+
+ Assert.Equal(2, tarefas.Count);
+ Assert.Single(ideias);
+ Assert.All(tarefas, n => Assert.Equal("Tarefas", n.Category));
+ }
+
+ [Fact]
+ public async Task GetByCategoryAsync_NoMatch_ShouldReturnEmpty()
+ {
+ await _repository.SaveAsync(new Note { Title = "Nota", Category = "Tarefas" });
+
+ var result = await _repository.GetByCategoryAsync("Inexistente");
+
+ Assert.Empty(result);
+ }
+}
diff --git a/tests/VoiceNotesAI.Tests/Services/SpeechToTextServiceTests.cs b/tests/VoiceNotesAI.Tests/Services/SpeechToTextServiceTests.cs
new file mode 100644
index 0000000..e36df41
--- /dev/null
+++ b/tests/VoiceNotesAI.Tests/Services/SpeechToTextServiceTests.cs
@@ -0,0 +1,128 @@
+using System.Net;
+using System.Text;
+using Moq;
+using Moq.Protected;
+using VoiceNotesAI.Services;
+
+namespace VoiceNotesAI.Tests.Services;
+
+public class SpeechToTextServiceTests
+{
+ [Fact]
+ public async Task TranscribeAsync_FileNotFound_ShouldThrowFileNotFoundException()
+ {
+ var httpClient = new HttpClient();
+ var service = new SpeechToTextService(httpClient, "test-key");
+
+ await Assert.ThrowsAsync(
+ () => service.TranscribeAsync("/path/that/does/not/exist.wav"));
+ }
+
+ [Fact]
+ public async Task TranscribeAsync_ValidFile_ShouldReturnTranscribedText()
+ {
+ // Create a temp audio file
+ var tempFile = Path.GetTempFileName();
+ await File.WriteAllBytesAsync(tempFile, new byte[] { 0x00, 0x01, 0x02 });
+
+ try
+ {
+ var responseJson = """{"text": "Olá, este é um teste de transcrição"}""";
+
+ var handler = new Mock();
+ handler
+ .Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.OK,
+ Content = new StringContent(responseJson, Encoding.UTF8, "application/json")
+ });
+
+ var httpClient = new HttpClient(handler.Object);
+ var service = new SpeechToTextService(httpClient, "test-key", "whisper-1");
+
+ var result = await service.TranscribeAsync(tempFile);
+
+ Assert.Equal("Olá, este é um teste de transcrição", result);
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+
+ [Fact]
+ public async Task TranscribeAsync_ShouldSendAuthorizationHeader()
+ {
+ var tempFile = Path.GetTempFileName();
+ await File.WriteAllBytesAsync(tempFile, new byte[] { 0x00 });
+
+ try
+ {
+ HttpRequestMessage? capturedRequest = null;
+ var handler = new Mock();
+ handler
+ .Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .Callback((req, _) => capturedRequest = req)
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.OK,
+ Content = new StringContent("""{"text": "teste"}""", Encoding.UTF8, "application/json")
+ });
+
+ var httpClient = new HttpClient(handler.Object);
+ var service = new SpeechToTextService(httpClient, "my-whisper-key");
+
+ await service.TranscribeAsync(tempFile);
+
+ Assert.NotNull(capturedRequest);
+ Assert.Equal("Bearer", capturedRequest.Headers.Authorization?.Scheme);
+ Assert.Equal("my-whisper-key", capturedRequest.Headers.Authorization?.Parameter);
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+
+ [Fact]
+ public async Task TranscribeAsync_ApiError_ShouldThrowHttpRequestException()
+ {
+ var tempFile = Path.GetTempFileName();
+ await File.WriteAllBytesAsync(tempFile, new byte[] { 0x00 });
+
+ try
+ {
+ var handler = new Mock();
+ handler
+ .Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.InternalServerError,
+ Content = new StringContent("Server Error")
+ });
+
+ var httpClient = new HttpClient(handler.Object);
+ var service = new SpeechToTextService(httpClient, "key");
+
+ await Assert.ThrowsAsync(
+ () => service.TranscribeAsync(tempFile));
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+}
diff --git a/tests/VoiceNotesAI.Tests/VoiceNotesAI.Tests.csproj b/tests/VoiceNotesAI.Tests/VoiceNotesAI.Tests.csproj
new file mode 100644
index 0000000..2a395e8
--- /dev/null
+++ b/tests/VoiceNotesAI.Tests/VoiceNotesAI.Tests.csproj
@@ -0,0 +1,37 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+