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 @@ + + + + + + + +