diff --git a/app/src/main/java/com/sayanthrock/freeairock/MainActivity.kt b/app/src/main/java/com/sayanthrock/freeairock/MainActivity.kt index 24d6c75..a592281 100644 --- a/app/src/main/java/com/sayanthrock/freeairock/MainActivity.kt +++ b/app/src/main/java/com/sayanthrock/freeairock/MainActivity.kt @@ -4,32 +4,108 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModelProvider +import com.sayanthrock.freeairock.data.ai.CodeAnalysisState +import com.sayanthrock.freeairock.data.github.GitHubApiService import com.sayanthrock.freeairock.data.storage.SecureStorageManager import com.sayanthrock.freeairock.ui.AboutScreen +import com.sayanthrock.freeairock.ui.AppViewModel import com.sayanthrock.freeairock.ui.AppViewModelFactory import com.sayanthrock.freeairock.ui.HomeScaffold -import com.sayanthrock.freeairock.ui.ImageStudioScreen import com.sayanthrock.freeairock.ui.ImageViewModel +import com.sayanthrock.freeairock.ui.PlaceholderPanel +import com.sayanthrock.freeairock.ui.ImageStudioScreen +import com.sayanthrock.freeairock.data.ai.PollinationsApiService +import retrofit2.converter.scalars.ScalarsConverterFactory +import com.sayanthrock.freeairock.ui.ReviewScreen +import com.sayanthrock.freeairock.ui.ReviewViewModel import com.sayanthrock.freeairock.ui.ThemeMode import com.sayanthrock.freeairock.ui.theme.FreeAiRockTheme +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory class MainActivity : ComponentActivity() { private val secureStorage by lazy { SecureStorageManager(this) } + private val okHttpClient by lazy { + OkHttpClient.Builder() + .addInterceptor { chain -> + val requestBuilder = chain.request().newBuilder() + val token = secureStorage.getGitHubToken() + + if (!token.isNullOrBlank()) { + requestBuilder.header("Authorization", "Bearer $token") + } + + chain.proceed(requestBuilder.build()) + } + .build() + } + + private val githubApiService by lazy { + Retrofit.Builder() + .baseUrl("https://api.github.com/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(GitHubApiService::class.java) + } + + + private val pollinationsApiService by lazy { + Retrofit.Builder() + .baseUrl("https://text.pollinations.ai/") + .client(OkHttpClient.Builder().build()) + .addConverterFactory(ScalarsConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(PollinationsApiService::class.java) + } + private val viewModelFactory by lazy { - AppViewModelFactory(secureStorage) + AppViewModelFactory(secureStorage, githubApiService, pollinationsApiService) } private val viewModelProvider by lazy { ViewModelProvider(this, viewModelFactory) } + private val appViewModel: AppViewModel by lazy { + viewModelProvider[AppViewModel::class.java] + } + + private val reviewViewModel: ReviewViewModel by lazy { + viewModelProvider[ReviewViewModel::class.java] + } + private val imageViewModel: ImageViewModel by lazy { viewModelProvider[ImageViewModel::class.java] } @@ -47,6 +123,24 @@ class MainActivity : ComponentActivity() { FreeAiRockTheme(darkTheme = darkTheme) { HomeScaffold( + codeContent = { modifier -> + CodeAnalyzerScreen( + uiState = appViewModel.analysisState.collectAsState().value, + onSave = { githubToken -> + appViewModel.saveKeys(githubToken) + imageViewModel.refreshRenderer() + }, + onAnalyze = appViewModel::analyzeCodeFile, + onReset = appViewModel::resetAnalysis, + modifier = modifier + ) + }, + reviewContent = { modifier -> + ReviewScreen( + viewModel = reviewViewModel, + modifier = modifier + ) + }, studioContent = { modifier -> ImageStudioScreen(modifier = modifier) }, @@ -62,3 +156,166 @@ class MainActivity : ComponentActivity() { } } } + +@Composable +private fun CodeAnalyzerScreen( + uiState: CodeAnalysisState, + onSave: (githubToken: String) -> Unit, + onAnalyze: (fileName: String, downloadUrl: String?) -> Unit, + onReset: () -> Unit, + modifier: Modifier = Modifier +) { + var githubToken by remember { mutableStateOf("") } + + var fileName by remember { mutableStateOf("MainActivity.kt") } + var fileUrl by remember { mutableStateOf("") } + var savedMessage by remember { mutableStateOf(null) } + val clipboardManager = LocalClipboardManager.current + + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp) + ) { + Text( + text = "FREE-AI-ROCK", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Secure setup for GitHub file analysis and AI code summaries.", + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(24.dp)) + + OutlinedTextField( + value = githubToken, + onValueChange = { githubToken = it }, + label = { Text("GitHub token") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + onSave(githubToken) + savedMessage = "Saved securely on this device" + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Save Securely") + } + + savedMessage?.let { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = it, + color = MaterialTheme.colorScheme.onBackground + ) + } + + Spacer(modifier = Modifier.height(28.dp)) + + Text( + text = "Code AI", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = fileName, + onValueChange = { fileName = it }, + label = { Text("File name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = fileUrl, + onValueChange = { fileUrl = it }, + label = { Text("GitHub raw/blob file URL") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { onAnalyze(fileName, fileUrl) }, + enabled = uiState !is CodeAnalysisState.Loading && fileUrl.isNotBlank(), + modifier = Modifier.fillMaxWidth() + ) { + Text(if (uiState is CodeAnalysisState.Loading) "Analyzing..." else "Summarize Code with AI") + } + + Spacer(modifier = Modifier.height(20.dp)) + + when (uiState) { + CodeAnalysisState.Idle -> Text( + text = "Paste a GitHub raw URL or normal blob URL, then run the analyzer.", + color = MaterialTheme.colorScheme.onBackground + ) + + CodeAnalysisState.Loading -> CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary + ) + + is CodeAnalysisState.Success -> { + Surface( + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = uiState.result, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedButton( + onClick = { clipboardManager.setText(AnnotatedString(uiState.result)) }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Copy Result") + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = onReset, + modifier = Modifier.fillMaxWidth() + ) { + Text("Reset") + } + } + + is CodeAnalysisState.Error -> Text( + text = "Error: ${uiState.message}", + color = MaterialTheme.colorScheme.error + ) + } + } + } +} diff --git a/app/src/main/java/com/sayanthrock/freeairock/data/ai/AiCodeAnalyzer.kt b/app/src/main/java/com/sayanthrock/freeairock/data/ai/AiCodeAnalyzer.kt new file mode 100644 index 0000000..586f2a9 --- /dev/null +++ b/app/src/main/java/com/sayanthrock/freeairock/data/ai/AiCodeAnalyzer.kt @@ -0,0 +1,81 @@ +package com.sayanthrock.freeairock.data.ai + +class AiCodeAnalyzer(private val pollinationsApiService: PollinationsApiService) { + + suspend fun analyzeCode(fileName: String, rawCode: String): String { + val safeFileName = fileName.ifBlank { "selected-file" } + val prompt = buildString { + appendLine("You are an expert software developer.") + appendLine("Analyze the file named '$safeFileName'.") + appendLine("Explain its core functionality, architecture role, important methods, risks, and improvement ideas.") + appendLine("Keep the explanation clear for a junior developer.") + appendLine() + appendLine("Code:") + appendLine("```") + appendLine(rawCode.take(MAX_CODE_CHARS)) + appendLine("```") + } + + return generateText(prompt, "No explanation generated.") + } + + suspend fun summarizePullRequest(diffCode: String): String { + val prompt = buildString { + appendLine("You are an expert lead developer reviewing a pull request.") + appendLine("Analyze the following Git diff.") + appendLine("Do not read line-by-line changes back to the user.") + appendLine("Explain the high-level purpose, likely bug fix or feature, architecture impact, risk, and testing notes.") + appendLine("Keep the explanation approachable for a junior developer.") + appendLine() + appendLine("Git diff:") + appendLine("```diff") + appendLine(diffCode.take(MAX_DIFF_CHARS)) + appendLine("```") + } + + return generateText(prompt, "No pull request explanation generated.") + } + + suspend fun summarizePullRequest(owner: String, repo: String, pullNumber: Int, diffText: String): String { + val safeOwner = owner.ifBlank { "owner" } + val safeRepo = repo.ifBlank { "repo" } + val prompt = buildString { + appendLine("You are a senior code reviewer.") + appendLine("Summarize pull request #$pullNumber in $safeOwner/$safeRepo using the Git diff below.") + appendLine("Focus on developer impact, affected files, behavior changes, risks, testing notes, and release notes.") + appendLine("Use clear plain English. Avoid repeating every line of the diff.") + appendLine() + appendLine("Return this structure:") + appendLine("1. Summary") + appendLine("2. Key changes") + appendLine("3. Risk level") + appendLine("4. Testing checklist") + appendLine("5. Suggested release note") + appendLine() + appendLine("Git diff:") + appendLine("```diff") + appendLine(diffText.take(MAX_DIFF_CHARS)) + appendLine("```") + } + + return generateText(prompt, "No pull request summary generated.") + } + + private suspend fun generateText(prompt: String, fallback: String): String { + return try { + val response = pollinationsApiService.generateText( + PollinationsRequest( + messages = listOf(PollinationsMessage(role = "user", content = prompt)) + ) + ) + response.takeIf { it.isNotBlank() } ?: fallback + } catch (error: Exception) { + "AI analysis failed: ${error.localizedMessage ?: "Unknown error"}" + } + } + + companion object { + private const val MAX_CODE_CHARS = 12000 + private const val MAX_DIFF_CHARS = 18000 + } +} diff --git a/app/src/main/java/com/sayanthrock/freeairock/data/ai/CodeAnalysisState.kt b/app/src/main/java/com/sayanthrock/freeairock/data/ai/CodeAnalysisState.kt new file mode 100644 index 0000000..8be092d --- /dev/null +++ b/app/src/main/java/com/sayanthrock/freeairock/data/ai/CodeAnalysisState.kt @@ -0,0 +1,8 @@ +package com.sayanthrock.freeairock.data.ai + +sealed interface CodeAnalysisState { + data object Idle : CodeAnalysisState + data object Loading : CodeAnalysisState + data class Success(val result: String) : CodeAnalysisState + data class Error(val message: String) : CodeAnalysisState +} diff --git a/app/src/main/java/com/sayanthrock/freeairock/data/ai/PollinationsApiService.kt b/app/src/main/java/com/sayanthrock/freeairock/data/ai/PollinationsApiService.kt new file mode 100644 index 0000000..712ca47 --- /dev/null +++ b/app/src/main/java/com/sayanthrock/freeairock/data/ai/PollinationsApiService.kt @@ -0,0 +1,21 @@ +package com.sayanthrock.freeairock.data.ai + +import retrofit2.http.Body +import retrofit2.http.POST + +data class PollinationsMessage( + val role: String, + val content: String +) + +data class PollinationsRequest( + val messages: List, + val model: String? = null +) + +interface PollinationsApiService { + @POST("/") + suspend fun generateText( + @Body request: PollinationsRequest + ): String +} diff --git a/app/src/main/java/com/sayanthrock/freeairock/data/github/GitHubApiService.kt b/app/src/main/java/com/sayanthrock/freeairock/data/github/GitHubApiService.kt new file mode 100644 index 0000000..970616d --- /dev/null +++ b/app/src/main/java/com/sayanthrock/freeairock/data/github/GitHubApiService.kt @@ -0,0 +1,37 @@ +package com.sayanthrock.freeairock.data.github + +import okhttp3.ResponseBody +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.Path +import retrofit2.http.Query +import retrofit2.http.Url + +interface GitHubApiService { + + @GET("user/repos") + suspend fun getMyRepositories( + @Query("sort") sort: String = "updated", + @Query("per_page") perPage: Int = 100 + ): List + + @GET("repos/{owner}/{repo}/contents/{path}") + suspend fun getRepositoryContent( + @Path("owner") owner: String, + @Path("repo") repo: String, + @Path(value = "path", encoded = true) path: String = "" + ): List + + @Headers("Accept: application/vnd.github.v3.diff") + @GET("repos/{owner}/{repo}/pulls/{pullNumber}") + suspend fun getPullRequestDiff( + @Path("owner") owner: String, + @Path("repo") repo: String, + @Path("pullNumber") pullNumber: Int + ): ResponseBody + + @GET + suspend fun downloadRawFile( + @Url fileUrl: String + ): ResponseBody +} diff --git a/app/src/main/java/com/sayanthrock/freeairock/data/github/GitHubModels.kt b/app/src/main/java/com/sayanthrock/freeairock/data/github/GitHubModels.kt new file mode 100644 index 0000000..7e82f8f --- /dev/null +++ b/app/src/main/java/com/sayanthrock/freeairock/data/github/GitHubModels.kt @@ -0,0 +1,32 @@ +package com.sayanthrock.freeairock.data.github + +import com.google.gson.annotations.SerializedName + +/** + * Represents a GitHub repository returned from GET /user/repos. + */ +data class GitHubRepo( + val id: Long, + val name: String, + @SerializedName("full_name") val fullName: String, + @SerializedName("private") val isPrivate: Boolean, + @SerializedName("html_url") val htmlUrl: String, + val description: String?, + @SerializedName("default_branch") val defaultBranch: String +) + +/** + * Represents a file or directory returned from GET /repos/{owner}/{repo}/contents/{path}. + */ +data class GitHubContentItem( + val name: String, + val path: String, + val sha: String, + val size: Int, + val type: String, + @SerializedName("html_url") val htmlUrl: String?, + @SerializedName("download_url") val downloadUrl: String? +) { + val isFile: Boolean get() = type == "file" + val isDirectory: Boolean get() = type == "dir" +} diff --git a/app/src/main/java/com/sayanthrock/freeairock/ui/AppViewModel.kt b/app/src/main/java/com/sayanthrock/freeairock/ui/AppViewModel.kt new file mode 100644 index 0000000..455b620 --- /dev/null +++ b/app/src/main/java/com/sayanthrock/freeairock/ui/AppViewModel.kt @@ -0,0 +1,74 @@ +package com.sayanthrock.freeairock.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sayanthrock.freeairock.data.ai.AiCodeAnalyzer +import com.sayanthrock.freeairock.data.ai.CodeAnalysisState +import com.sayanthrock.freeairock.data.github.GitHubApiService +import com.sayanthrock.freeairock.data.ai.PollinationsApiService +import com.sayanthrock.freeairock.data.storage.SecureStorageManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class AppViewModel( + private val secureStorage: SecureStorageManager, + private val githubApiService: GitHubApiService, + private val pollinationsApiService: PollinationsApiService +) : ViewModel() { + + private val _analysisState = MutableStateFlow(CodeAnalysisState.Idle) + val analysisState: StateFlow = _analysisState.asStateFlow() + + private var aiAnalyzer: AiCodeAnalyzer? = AiCodeAnalyzer(pollinationsApiService) + + fun refreshAiAnalyzer() { + aiAnalyzer = AiCodeAnalyzer(pollinationsApiService) + } + + fun saveKeys(githubToken: String) { + secureStorage.saveGitHubToken(githubToken) + refreshAiAnalyzer() + } + + fun analyzeCodeFile(fileName: String, downloadUrl: String?) { + viewModelScope.launch { + _analysisState.value = CodeAnalysisState.Loading + + try { + val rawUrl = resolveDownloadUrl(downloadUrl) + val analyzer = aiAnalyzer ?: error("AI Analyzer not initialized") + val rawCode = githubApiService.downloadRawFile(rawUrl).string() + val result = analyzer.analyzeCode(fileName, rawCode) + + _analysisState.value = CodeAnalysisState.Success(result) + } catch (error: Exception) { + _analysisState.value = CodeAnalysisState.Error( + error.localizedMessage ?: "Unknown analysis error" + ) + } + } + } + + fun resetAnalysis() { + _analysisState.value = CodeAnalysisState.Idle + } + + private fun resolveDownloadUrl(input: String?): String { + val value = input?.trim()?.takeIf { it.isNotBlank() } + ?: error("GitHub file URL missing") + + return when { + value.contains("raw.githubusercontent.com/") -> value + value.contains("github.com/") && value.contains("/blob/") -> { + value + .substringBefore("?") + .replace("https://github.com/", "https://raw.githubusercontent.com/") + .replace("http://github.com/", "https://raw.githubusercontent.com/") + .replace("/blob/", "/") + } + else -> value + } + } +} diff --git a/app/src/main/java/com/sayanthrock/freeairock/ui/AppViewModelFactory.kt b/app/src/main/java/com/sayanthrock/freeairock/ui/AppViewModelFactory.kt index e543ae5..e7db795 100644 --- a/app/src/main/java/com/sayanthrock/freeairock/ui/AppViewModelFactory.kt +++ b/app/src/main/java/com/sayanthrock/freeairock/ui/AppViewModelFactory.kt @@ -2,15 +2,27 @@ package com.sayanthrock.freeairock.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import com.sayanthrock.freeairock.data.github.GitHubApiService +import com.sayanthrock.freeairock.data.ai.PollinationsApiService import com.sayanthrock.freeairock.data.storage.SecureStorageManager class AppViewModelFactory( - private val secureStorage: SecureStorageManager + private val secureStorage: SecureStorageManager, + private val apiService: GitHubApiService, + private val pollinationsApiService: PollinationsApiService ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { return when { + modelClass.isAssignableFrom(ReviewViewModel::class.java) -> { + ReviewViewModel(secureStorage, apiService, pollinationsApiService) as T + } + + modelClass.isAssignableFrom(AppViewModel::class.java) -> { + AppViewModel(secureStorage, apiService, pollinationsApiService) as T + } + modelClass.isAssignableFrom(ImageViewModel::class.java) -> { ImageViewModel(secureStorage) as T } diff --git a/app/src/main/java/com/sayanthrock/freeairock/ui/CodeResultScreen.kt b/app/src/main/java/com/sayanthrock/freeairock/ui/CodeResultScreen.kt new file mode 100644 index 0000000..319eb5c --- /dev/null +++ b/app/src/main/java/com/sayanthrock/freeairock/ui/CodeResultScreen.kt @@ -0,0 +1,187 @@ +package com.sayanthrock.freeairock.ui + +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.sayanthrock.freeairock.data.ai.CodeAnalysisState +import java.io.FileOutputStream + +@Composable +fun CodeResultScreen( + fileName: String, + uiState: CodeAnalysisState, + onAnalyzeClick: () -> Unit, + modifier: Modifier = Modifier +) { + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + var currentAiResult by remember { mutableStateOf("") } + var saveMessage by remember { mutableStateOf(null) } + + val createDocumentLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("text/plain") + ) { uri: Uri? -> + saveMessage = if (uri != null) { + saveTextToFile(context, uri, currentAiResult) + } else { + "Save cancelled" + } + } + + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Column( + modifier = Modifier + .padding(24.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = "FREE-AI-ROCK", + style = MaterialTheme.typography.headlineMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "File: $fileName", + style = MaterialTheme.typography.titleMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onAnalyzeClick, + enabled = uiState !is CodeAnalysisState.Loading, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Text(if (uiState is CodeAnalysisState.Loading) "Analyzing..." else "Summarize Code with AI") + } + + Spacer(modifier = Modifier.height(24.dp)) + + when (uiState) { + CodeAnalysisState.Idle -> { + Text( + text = "Select a GitHub file and run AI analysis.", + color = MaterialTheme.colorScheme.onBackground + ) + } + + CodeAnalysisState.Loading -> { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + + is CodeAnalysisState.Success -> { + currentAiResult = uiState.result + + Surface( + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = currentAiResult, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = { + clipboardManager.setText(AnnotatedString(currentAiResult)) + saveMessage = "Copied" + }, + modifier = Modifier.weight(1f) + ) { + Text("Copy") + } + + Button( + onClick = { + val safeName = fileName.replace(Regex("[^A-Za-z0-9._-]"), "_") + createDocumentLauncher.launch("AI_Analysis_$safeName.txt") + }, + modifier = Modifier.weight(1f) + ) { + Text("Save") + } + } + } + + is CodeAnalysisState.Error -> { + Text( + text = "Error: ${uiState.message}", + color = MaterialTheme.colorScheme.error + ) + } + } + + saveMessage?.let { message -> + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = message, + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} + +fun saveTextToFile(context: Context, uri: Uri, content: String): String { + return try { + context.contentResolver.openFileDescriptor(uri, "w")?.use { descriptor -> + FileOutputStream(descriptor.fileDescriptor).use { outputStream -> + outputStream.write(content.toByteArray()) + } + } + "Saved successfully" + } catch (error: Exception) { + "Save failed: ${error.localizedMessage ?: "Unknown error"}" + } +} diff --git a/app/src/main/java/com/sayanthrock/freeairock/ui/HomeScaffold.kt b/app/src/main/java/com/sayanthrock/freeairock/ui/HomeScaffold.kt index c0023e1..c07c9ca 100644 --- a/app/src/main/java/com/sayanthrock/freeairock/ui/HomeScaffold.kt +++ b/app/src/main/java/com/sayanthrock/freeairock/ui/HomeScaffold.kt @@ -25,11 +25,13 @@ import com.sayanthrock.freeairock.ui.navigation.Screen @Composable fun HomeScaffold( + codeContent: @Composable (Modifier) -> Unit, + reviewContent: @Composable (Modifier) -> Unit, studioContent: @Composable (Modifier) -> Unit, aboutContent: @Composable (Modifier) -> Unit ) { val navController = rememberNavController() - val screens = listOf(Screen.ImageStudio, Screen.About) + val screens = listOf(Screen.CodeAnalyzer, Screen.PullRequestReview, Screen.ImageStudio, Screen.About) Scaffold( topBar = { @@ -70,9 +72,11 @@ fun HomeScaffold( ) { innerPadding -> NavHost( navController = navController, - startDestination = Screen.ImageStudio.route, + startDestination = Screen.CodeAnalyzer.route, modifier = Modifier.padding(innerPadding) ) { + composable(Screen.CodeAnalyzer.route) { codeContent(Modifier) } + composable(Screen.PullRequestReview.route) { reviewContent(Modifier) } composable(Screen.ImageStudio.route) { studioContent(Modifier) } composable(Screen.About.route) { aboutContent(Modifier) } } diff --git a/app/src/main/java/com/sayanthrock/freeairock/ui/ReviewScreen.kt b/app/src/main/java/com/sayanthrock/freeairock/ui/ReviewScreen.kt new file mode 100644 index 0000000..d63a862 --- /dev/null +++ b/app/src/main/java/com/sayanthrock/freeairock/ui/ReviewScreen.kt @@ -0,0 +1,157 @@ +package com.sayanthrock.freeairock.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.sayanthrock.freeairock.data.ai.CodeAnalysisState + +@Composable +fun ReviewScreen( + viewModel: ReviewViewModel, + modifier: Modifier = Modifier +) { + val uiState by viewModel.state.collectAsState() + var ownerText by remember { mutableStateOf("SayanthRock") } + var repoText by remember { mutableStateOf("Root-apk") } + var prNumberText by remember { mutableStateOf("") } + val clipboardManager = LocalClipboardManager.current + + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "PR Review AI", + style = MaterialTheme.typography.headlineMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.align(Alignment.Start) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = ownerText, + onValueChange = { ownerText = it }, + label = { Text("Owner") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + + OutlinedTextField( + value = repoText, + onValueChange = { repoText = it }, + label = { Text("Repo") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = prNumberText, + onValueChange = { prNumberText = it }, + label = { Text("Pull Request #") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { viewModel.run(ownerText, repoText, prNumberText) }, + modifier = Modifier.fillMaxWidth(), + enabled = uiState !is CodeAnalysisState.Loading && prNumberText.isNotBlank() + ) { + Text(if (uiState is CodeAnalysisState.Loading) "Analyzing Diff..." else "Summarize PR") + } + + Spacer(modifier = Modifier.height(32.dp)) + + when (val state = uiState) { + is CodeAnalysisState.Success -> { + Surface( + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = state.result, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedButton( + onClick = { clipboardManager.setText(AnnotatedString(state.result)) }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Copy Review") + } + } + + is CodeAnalysisState.Error -> { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = state.message, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium + ) + } + } + + CodeAnalysisState.Loading -> { + Text( + text = "Reading pull request diff and creating review...", + color = MaterialTheme.colorScheme.onBackground + ) + } + + CodeAnalysisState.Idle -> Unit + } + } +} diff --git a/app/src/main/java/com/sayanthrock/freeairock/ui/ReviewViewModel.kt b/app/src/main/java/com/sayanthrock/freeairock/ui/ReviewViewModel.kt new file mode 100644 index 0000000..892c7c2 --- /dev/null +++ b/app/src/main/java/com/sayanthrock/freeairock/ui/ReviewViewModel.kt @@ -0,0 +1,46 @@ +package com.sayanthrock.freeairock.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sayanthrock.freeairock.data.ai.AiCodeAnalyzer +import com.sayanthrock.freeairock.data.ai.CodeAnalysisState +import com.sayanthrock.freeairock.data.github.GitHubApiService +import com.sayanthrock.freeairock.data.storage.SecureStorageManager +import com.sayanthrock.freeairock.data.ai.PollinationsApiService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class ReviewViewModel( + private val storage: SecureStorageManager, + private val service: GitHubApiService, + private val pollinationsApiService: PollinationsApiService +) : ViewModel() { + + private val _state = MutableStateFlow(CodeAnalysisState.Idle) + val state: StateFlow = _state.asStateFlow() + + private var cachedAnalyzer: AiCodeAnalyzer? = null + + fun run(owner: String, repo: String, numberText: String) { + viewModelScope.launch { + _state.value = CodeAnalysisState.Loading + try { + val number = numberText.trim().toIntOrNull() ?: error("Invalid number") + val diffText = service.getPullRequestDiff(owner.trim(), repo.trim(), number).string() + val result = getAnalyzer().summarizePullRequest(owner.trim(), repo.trim(), number, diffText) + _state.value = CodeAnalysisState.Success(result) + } catch (error: Exception) { + _state.value = CodeAnalysisState.Error(error.localizedMessage ?: "Review failed") + } + } + } + + private fun getAnalyzer(): AiCodeAnalyzer { + if (cachedAnalyzer == null) { + cachedAnalyzer = AiCodeAnalyzer(pollinationsApiService) + } + return cachedAnalyzer ?: error("AI analyzer unavailable") + } +} diff --git a/app/src/main/java/com/sayanthrock/freeairock/ui/navigation/Screen.kt b/app/src/main/java/com/sayanthrock/freeairock/ui/navigation/Screen.kt index 0ea2283..8fa641b 100644 --- a/app/src/main/java/com/sayanthrock/freeairock/ui/navigation/Screen.kt +++ b/app/src/main/java/com/sayanthrock/freeairock/ui/navigation/Screen.kt @@ -5,6 +5,18 @@ sealed class Screen( val title: String, val shortLabel: String ) { + data object CodeAnalyzer : Screen( + route = "code_analyzer", + title = "Code AI", + shortLabel = "AI" + ) + + data object PullRequestReview : Screen( + route = "pr_review", + title = "PR Review", + shortLabel = "PR" + ) + data object ImageStudio : Screen( route = "image_studio", title = "Image Studio", diff --git a/app/src/test/java/com/sayanthrock/freeairock/MainDispatcherRule.kt b/app/src/test/java/com/sayanthrock/freeairock/MainDispatcherRule.kt new file mode 100644 index 0000000..d802323 --- /dev/null +++ b/app/src/test/java/com/sayanthrock/freeairock/MainDispatcherRule.kt @@ -0,0 +1,24 @@ +package com.sayanthrock.freeairock + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() +) : TestWatcher() { + + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/app/src/test/java/com/sayanthrock/freeairock/ui/AppViewModelFactoryTest.kt b/app/src/test/java/com/sayanthrock/freeairock/ui/AppViewModelFactoryTest.kt new file mode 100644 index 0000000..223a829 --- /dev/null +++ b/app/src/test/java/com/sayanthrock/freeairock/ui/AppViewModelFactoryTest.kt @@ -0,0 +1,61 @@ +package com.sayanthrock.freeairock.ui + +import com.sayanthrock.freeairock.data.github.GitHubApiService +import com.sayanthrock.freeairock.data.ai.PollinationsApiService +import com.sayanthrock.freeairock.data.github.GitHubContentItem +import com.sayanthrock.freeairock.data.github.GitHubRepo +import com.sayanthrock.freeairock.data.storage.SecureStorageManager +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import io.mockk.mockk + +class AppViewModelFactoryTest { + + private val secureStorage = SecureStorageManager() + private val apiService = FakeGitHubApiService() + private lateinit var factory: AppViewModelFactory + + @Before + fun setup() { + secureStorage.clearSecrets() + factory = AppViewModelFactory(secureStorage, apiService, mockk()) + } + + @Test + fun `creates AppViewModel`() { + val viewModel = factory.create(AppViewModel::class.java) + + assertTrue(viewModel is AppViewModel) + } + + @Test + fun `creates ReviewViewModel`() { + val viewModel = factory.create(ReviewViewModel::class.java) + + assertTrue(viewModel is ReviewViewModel) + } + + @Test + fun `creates ImageViewModel`() { + val viewModel = factory.create(ImageViewModel::class.java) + + assertTrue(viewModel is ImageViewModel) + } + + @Test(expected = IllegalArgumentException::class) + fun `throws for unknown ViewModel`() { + factory.create(UnknownViewModel::class.java) + } + + private class UnknownViewModel : androidx.lifecycle.ViewModel() + + private class FakeGitHubApiService : GitHubApiService { + override suspend fun getMyRepositories(sort: String, perPage: Int): List = emptyList() + override suspend fun getRepositoryContent(owner: String, repo: String, path: String): List = emptyList() + override suspend fun getPullRequestDiff(owner: String, repo: String, pullNumber: Int): ResponseBody = "".toResponseBody() + override suspend fun downloadRawFile(fileUrl: String): ResponseBody = "".toResponseBody() + } +} diff --git a/app/src/test/java/com/sayanthrock/freeairock/ui/ReviewViewModelTest.kt b/app/src/test/java/com/sayanthrock/freeairock/ui/ReviewViewModelTest.kt new file mode 100644 index 0000000..bfe9a55 --- /dev/null +++ b/app/src/test/java/com/sayanthrock/freeairock/ui/ReviewViewModelTest.kt @@ -0,0 +1,65 @@ +package com.sayanthrock.freeairock.ui + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.sayanthrock.freeairock.MainDispatcherRule +import com.sayanthrock.freeairock.data.ai.CodeAnalysisState +import com.sayanthrock.freeairock.data.github.GitHubApiService +import com.sayanthrock.freeairock.data.ai.PollinationsApiService +import com.sayanthrock.freeairock.data.ai.PollinationsRequest +import com.sayanthrock.freeairock.data.github.GitHubContentItem +import com.sayanthrock.freeairock.data.github.GitHubRepo +import com.sayanthrock.freeairock.data.storage.SecureStorageManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import io.mockk.mockk +import io.mockk.coEvery +import io.mockk.every + +@OptIn(ExperimentalCoroutinesApi::class) +class ReviewViewModelTest { + + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private lateinit var secureStorage: SecureStorageManager + private lateinit var githubApiService: GitHubApiService + private lateinit var pollinationsApiService: PollinationsApiService + private lateinit var viewModel: ReviewViewModel + + @Before + fun setup() { + secureStorage = SecureStorageManager() + githubApiService = mockk() + pollinationsApiService = mockk() + viewModel = ReviewViewModel(secureStorage, githubApiService, pollinationsApiService) + } + + @Test + fun `missing Gemini key updates state to Error`() = runTest { + viewModel.run("owner", "repo", "12") + assertTrue(viewModel.state.value is CodeAnalysisState.Error) + } + + @Test + fun `invalid pull request number updates state to Error`() = runTest { + + viewModel.run("owner", "repo", "not-a-number") + assertTrue(viewModel.state.value is CodeAnalysisState.Error) + } + + private class FakeGitHubApiService : GitHubApiService { + override suspend fun getMyRepositories(sort: String, perPage: Int): List = emptyList() + override suspend fun getRepositoryContent(owner: String, repo: String, path: String): List = emptyList() + override suspend fun getPullRequestDiff(owner: String, repo: String, pullNumber: Int): ResponseBody = "".toResponseBody() + override suspend fun downloadRawFile(fileUrl: String): ResponseBody = "".toResponseBody() + } +}