diff --git a/README.md b/README.md
index 8672d6c..75568c5 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,9 @@ Unlike other viewers that bundle heavy rendering engines (often adding 30-50MB),
### 3. Native "Open With" Integration
The app integrates directly with your system's file manager. You don't need to open the app first; just tap your file in "Downloads" or "Files" and select Simple Reader.
+### 4. Modern & Intuitive Design
+A completely redesigned home screen featuring smooth animations, gradient aesthetics, and quick access shortcuts, all while maintaining the app's lightweight nature.
+
## Supported Formats
* **PDF (.pdf)**: High-performance rendering using Android's built-in PDF engine. Supports pinch-to-zoom and page virtualization.
@@ -32,7 +35,7 @@ The app integrates directly with your system's file manager. You don't need to o
For developers interested in the implementation:
* **Language**: 100% Kotlin.
-* **UI**: Jetpack Compose (Material 3).
+* **UI**: Jetpack Compose (Material 3) with custom animations and dynamic gradient themes.
* **Architecture**: MVVM / Clean Architecture.
* **PDF**: Implemented via `android.graphics.pdf.PdfRenderer` with Bitmap recycling and LruCache for memory efficiency.
* **Office**: Uses a ProGuard-stripped version of Apache POI. Complex binaries are parsed to HTML/CSS for rendering in a restricted WebView.
diff --git a/README_VI.md b/README_VI.md
index 916d126..fb6f1b7 100644
--- a/README_VI.md
+++ b/README_VI.md
@@ -21,6 +21,9 @@ Không giống như các trình xem khác thường đóng gói các công cụ
### 3. Tích Hợp "Mở Bằng" (Open With) Tự Nhiên
Ứng dụng tích hợp trực tiếp với trình quản lý tệp của hệ thống. Bạn không cần phải mở ứng dụng trước; chỉ cần nhấn vào tệp của bạn trong "Tải xuống" hoặc "Tệp" và chọn Simple Reader.
+### 4. Thiết Kế Hiện Đại & Trực Quan
+Màn hình chính được thiết kế lại hoàn toàn với các hiệu ứng hoạt hình mượt mà, giao diện gradient đẹp mắt và các phím tắt truy cập nhanh, trong khi vẫn giữ được sự nhẹ nhàng vốn có của ứng dụng.
+
## Các Định Dạng Được Hỗ Trợ
* **PDF (.pdf)**: Hiển thị hiệu suất cao sử dụng công cụ PDF tích hợp sẵn của Android. Hỗ trợ thu phóng bằng hai ngón tay và ảo hóa trang.
@@ -32,7 +35,7 @@ Không giống như các trình xem khác thường đóng gói các công cụ
Dành cho các lập trình viên quan tâm đến việc triển khai:
* **Ngôn Ngữ**: 100% Kotlin.
-* **Giao Diện (UI)**: Jetpack Compose (Material 3).
+* **Giao Diện (UI)**: Jetpack Compose (Material 3) với các hiệu ứng hoạt hình tùy chỉnh và chủ đề gradient động.
* **Kiến Trúc**: MVVM / Kiến Trúc Sạch (Clean Architecture).
* **PDF**: Được triển khai thông qua `android.graphics.pdf.PdfRenderer` với việc tái sử dụng Bitmap và LruCache để tiết kiệm bộ nhớ.
* **Office**: Sử dụng phiên bản rút gọn của Apache POI (đã qua ProGuard). Các tệp nhị phân phức tạp được phân tích thành HTML/CSS để hiển thị trong WebView bị hạn chế quyền.
diff --git a/app/src/main/java/com/zeq/simple/reader/parser/DocxParser.kt b/app/src/main/java/com/zeq/simple/reader/parser/DocxParser.kt
index fc26da7..7e066d4 100644
--- a/app/src/main/java/com/zeq/simple/reader/parser/DocxParser.kt
+++ b/app/src/main/java/com/zeq/simple/reader/parser/DocxParser.kt
@@ -1,9 +1,11 @@
package com.zeq.simple.reader.parser
+import android.util.Base64
import android.util.Log
import org.apache.poi.xwpf.usermodel.UnderlinePatterns
import org.apache.poi.xwpf.usermodel.XWPFDocument
import org.apache.poi.xwpf.usermodel.XWPFParagraph
+import org.apache.poi.xwpf.usermodel.XWPFPicture
import org.apache.poi.xwpf.usermodel.XWPFTable
import java.io.BufferedInputStream
import java.io.InputStream
@@ -67,6 +69,46 @@ class DocxParser {
strong { font-weight: 600; }
em { font-style: italic; }
u { text-decoration: underline; }
+ img {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ margin: 12px 0;
+ border-radius: 4px;
+ }
+ .image-container {
+ text-align: center;
+ margin: 16px 0;
+ }
+ .page-break {
+ border-top: 2px dashed #9e9e9e;
+ margin: 24px 0;
+ padding-top: 24px;
+ position: relative;
+ }
+ .page-break::before {
+ content: '--- Page Break ---';
+ display: block;
+ text-align: center;
+ color: #757575;
+ font-size: 12px;
+ font-weight: 500;
+ background-color: #ffffff;
+ padding: 4px 12px;
+ position: absolute;
+ top: -10px;
+ left: 50%;
+ transform: translateX(-50%);
+ }
+ @media (prefers-color-scheme: dark) {
+ .page-break {
+ border-color: #616161;
+ }
+ .page-break::before {
+ background-color: #121212;
+ color: #9e9e9e;
+ }
+ }
@@ -124,28 +166,179 @@ class DocxParser {
}
/**
- * Processes a single paragraph, applying basic styling.
+ * Processes a single paragraph, applying basic styling and extracting images.
*/
private fun processParagraph(paragraph: XWPFParagraph): String {
- val text = paragraph.text?.trim() ?: return ""
- if (text.isEmpty()) return "
\n"
+ val builder = StringBuilder()
+
+ // Check for page break before this paragraph
+ if (hasPageBreakBefore(paragraph)) {
+ builder.append("\n")
+ }
+
+ // Check for embedded images first
+ val images = extractImagesFromParagraph(paragraph)
+ if (images.isNotEmpty()) {
+ images.forEach { imageHtml ->
+ builder.append(imageHtml)
+ }
+ // Also include any text in the paragraph
+ val text = paragraph.text?.trim() ?: ""
+ if (text.isNotEmpty()) {
+ val escapedText = escapeHtml(text)
+ val style = paragraph.style?.lowercase() ?: ""
+ when {
+ style.contains("heading1") || style.contains("title") ->
+ builder.append("$escapedText
\n")
+ style.contains("heading2") ->
+ builder.append("$escapedText
\n")
+ style.contains("heading3") ->
+ builder.append("$escapedText
\n")
+ else -> {
+ val formattedText = processInlineFormatting(paragraph)
+ builder.append("$formattedText
\n")
+ }
+ }
+ }
+ return builder.toString()
+ }
+
+ val text = paragraph.text?.trim() ?: ""
+ if (text.isEmpty()) {
+ // Even empty paragraphs can have page breaks
+ return if (builder.isNotEmpty()) builder.toString() else "
\n"
+ }
val escapedText = escapeHtml(text)
// Detect heading styles
val style = paragraph.style?.lowercase() ?: ""
- return when {
+ when {
style.contains("heading1") || style.contains("title") ->
- "$escapedText
\n"
+ builder.append("$escapedText
\n")
style.contains("heading2") ->
- "$escapedText
\n"
+ builder.append("$escapedText
\n")
style.contains("heading3") ->
- "$escapedText
\n"
+ builder.append("$escapedText
\n")
else -> {
// Apply inline formatting
val formattedText = processInlineFormatting(paragraph)
- "$formattedText
\n"
+ builder.append("$formattedText
\n")
+ }
+ }
+
+ return builder.toString()
+ }
+
+ /**
+ * Checks if a paragraph has a page break before it.
+ */
+ private fun hasPageBreakBefore(paragraph: XWPFParagraph): Boolean {
+ try {
+ // Check paragraph properties for page break
+ val ctp = paragraph.ctp
+ if (ctp != null && ctp.pPr != null) {
+ val pageBreakBefore = ctp.pPr.pageBreakBefore
+ if (pageBreakBefore != null) {
+ try {
+ // Try to get boolean value
+ val isPageBreak = pageBreakBefore.`val` == true
+ if (isPageBreak) return true
+ } catch (e: Exception) {
+ // If val property doesn't work, presence of pageBreakBefore element itself indicates true
+ return true
+ }
+ }
}
+
+ // Check runs for explicit page break
+ paragraph.runs?.forEach { run ->
+ try {
+ val ctr = run.ctr
+ if (ctr != null && ctr.brList != null) {
+ for (br in ctr.brList) {
+ if (br != null && br.type != null) {
+ val brType = br.type.toString()
+ if (brType == "PAGE" || brType.contains("page", ignoreCase = true)) {
+ return true
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Error checking run for page break", e)
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Error checking paragraph for page break", e)
+ }
+
+ return false
+ }
+
+ /**
+ * Extracts images from paragraph runs and converts to base64 HTML img tags.
+ */
+ private fun extractImagesFromParagraph(paragraph: XWPFParagraph): List {
+ val images = mutableListOf()
+
+ try {
+ paragraph.runs.forEach { run ->
+ val embeddedPictures = run.embeddedPictures
+ embeddedPictures?.forEach { picture ->
+ try {
+ val imageHtml = convertPictureToHtml(picture)
+ if (imageHtml != null) {
+ images.add(imageHtml)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to process embedded picture", e)
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to extract images from paragraph", e)
+ }
+
+ return images
+ }
+
+ /**
+ * Converts XWPFPicture to HTML img tag with base64 encoded data.
+ */
+ private fun convertPictureToHtml(picture: XWPFPicture): String? {
+ try {
+ val pictureData = picture.pictureData ?: return null
+ val imageBytes = pictureData.data ?: return null
+
+ // Convert to base64
+ val base64Image = Base64.encodeToString(imageBytes, Base64.NO_WRAP)
+
+ // Get MIME type from extension
+ val extension = pictureData.suggestFileExtension() ?: "png"
+ val mimeType = when (extension.lowercase()) {
+ "jpg", "jpeg" -> "image/jpeg"
+ "png" -> "image/png"
+ "gif" -> "image/gif"
+ "bmp" -> "image/bmp"
+ "webp" -> "image/webp"
+ else -> "image/png"
+ }
+
+ // Get dimensions if available
+ val width = picture.width
+ val height = picture.depth
+
+ val dimensionAttr = if (width > 0 && height > 0) {
+ " style=\"max-width: 100%; width: ${width}px; height: auto;\""
+ } else {
+ ""
+ }
+
+ return "\n"
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to convert picture to HTML", e)
+ return null
}
}
diff --git a/app/src/main/java/com/zeq/simple/reader/parser/XlsxParser.kt b/app/src/main/java/com/zeq/simple/reader/parser/XlsxParser.kt
index 5dcf3b3..51f7ec1 100644
--- a/app/src/main/java/com/zeq/simple/reader/parser/XlsxParser.kt
+++ b/app/src/main/java/com/zeq/simple/reader/parser/XlsxParser.kt
@@ -1,9 +1,13 @@
package com.zeq.simple.reader.parser
+import android.util.Base64
import android.util.Log
import org.apache.poi.ss.usermodel.Cell
import org.apache.poi.ss.usermodel.CellType
import org.apache.poi.ss.usermodel.DateUtil
+import org.apache.poi.xssf.usermodel.XSSFDrawing
+import org.apache.poi.xssf.usermodel.XSSFPicture
+import org.apache.poi.xssf.usermodel.XSSFSheet
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.io.BufferedInputStream
import java.io.InputStream
@@ -130,6 +134,19 @@ class XlsxParser {
htmlBuilder.append("⚠️ Showing first $MAX_ROWS rows only
\n")
}
+ // Extract and display images from the sheet
+ val sheetImages = extractImagesFromSheet(sheet)
+ if (sheetImages.isNotEmpty()) {
+ htmlBuilder.append("\n")
+ htmlBuilder.append("
📎 Images in this sheet (${sheetImages.size})
\n")
+ htmlBuilder.append("
\n")
+ sheetImages.forEach { imageHtml ->
+ htmlBuilder.append(imageHtml)
+ }
+ htmlBuilder.append("
\n")
+ htmlBuilder.append("
\n")
+ }
+
htmlBuilder.append("\n")
}
@@ -137,6 +154,65 @@ class XlsxParser {
return htmlBuilder.toString()
}
+ /**
+ * Extracts images from an Excel sheet and converts to base64 HTML.
+ */
+ private fun extractImagesFromSheet(sheet: XSSFSheet): List {
+ val images = mutableListOf()
+
+ try {
+ val drawing = sheet.drawingPatriarch
+ if (drawing is XSSFDrawing) {
+ val shapes = drawing.shapes
+ shapes.forEach { shape ->
+ if (shape is XSSFPicture) {
+ try {
+ val imageHtml = convertXSSFPictureToHtml(shape)
+ if (imageHtml != null) {
+ images.add(imageHtml)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to process picture in sheet", e)
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to extract images from sheet", e)
+ }
+
+ return images
+ }
+
+ /**
+ * Converts XSSFPicture to HTML img tag with base64 encoded data.
+ */
+ private fun convertXSSFPictureToHtml(picture: XSSFPicture): String? {
+ try {
+ val pictureData = picture.pictureData ?: return null
+ val imageBytes = pictureData.data ?: return null
+
+ // Convert to base64
+ val base64Image = Base64.encodeToString(imageBytes, Base64.NO_WRAP)
+
+ // Get MIME type from extension
+ val extension = pictureData.suggestFileExtension() ?: "png"
+ val mimeType = when (extension.lowercase()) {
+ "jpg", "jpeg" -> "image/jpeg"
+ "png" -> "image/png"
+ "gif" -> "image/gif"
+ "bmp" -> "image/bmp"
+ "webp" -> "image/webp"
+ else -> "image/png"
+ }
+
+ return "
\n"
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to convert picture to HTML", e)
+ return null
+ }
+ }
+
/**
* Extracts cell value as string, handling different cell types.
*/
@@ -317,10 +393,44 @@ class XlsxParser {
background-color: #fff3e0;
border-radius: 4px;
}
+ .sheet-images {
+ margin-top: 16px;
+ padding: 12px;
+ background-color: #fafafa;
+ border-radius: 4px;
+ }
+ .sheet-images h3 {
+ font-size: 14px;
+ margin: 0 0 8px 0;
+ font-weight: 600;
+ }
+ .image-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 8px;
+ }
+ .image-item {
+ max-width: 300px;
+ border: 1px solid #e0e0e0;
+ border-radius: 4px;
+ overflow: hidden;
+ }
+ .image-item img {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ }
@media (prefers-color-scheme: dark) {
.warning {
background-color: #3e2723;
}
+ .sheet-images {
+ background-color: #1e1e1e;
+ }
+ .image-item {
+ border-color: #424242;
+ }
}
$tabScript
diff --git a/app/src/main/java/com/zeq/simple/reader/ui/screens/HomeScreen.kt b/app/src/main/java/com/zeq/simple/reader/ui/screens/HomeScreen.kt
index 234503c..b1c0152 100644
--- a/app/src/main/java/com/zeq/simple/reader/ui/screens/HomeScreen.kt
+++ b/app/src/main/java/com/zeq/simple/reader/ui/screens/HomeScreen.kt
@@ -3,7 +3,12 @@ package com.zeq.simple.reader.ui.screens
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -11,20 +16,32 @@ 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.material3.Button
-import androidx.compose.material3.ButtonDefaults
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
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.LaunchedEffect
+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.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -37,7 +54,20 @@ fun HomeScreen(
onFileSelected: (Uri) -> Unit,
modifier: Modifier = Modifier
) {
- // File picker launcher for all supported document types
+ val uriHandler = LocalUriHandler.current
+ // Animation state
+ var isVisible by remember { mutableStateOf(false) }
+ val scale by animateFloatAsState(
+ targetValue = if (isVisible) 1f else 0.9f,
+ animationSpec = tween(durationMillis = 800),
+ label = "scale"
+ )
+
+ LaunchedEffect(Unit) {
+ isVisible = true
+ }
+
+ // File picker launcher
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
@@ -48,187 +78,254 @@ fun HomeScreen(
modifier = modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
+ Box(
+ modifier = Modifier.fillMaxSize()
) {
- // App icon/logo area
- Text(
- text = "📖",
- style = MaterialTheme.typography.displayLarge
- )
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Text(
- text = "Simple Reader",
- style = MaterialTheme.typography.headlineMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.primary
- )
-
- Spacer(modifier = Modifier.height(8.dp))
-
- Text(
- text = "Lightweight offline document viewer",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
+ // Background Decoration
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(350.dp)
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f),
+ Color.Transparent
+ )
+ )
+ )
)
- Spacer(modifier = Modifier.height(48.dp))
-
- // Supported formats card
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
- )
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp)
+ .scale(scale),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ // verticalArrangement = Arrangement.Center
) {
- Column(
- modifier = Modifier.padding(20.dp),
- horizontalAlignment = Alignment.CenterHorizontally
+ Spacer(modifier = Modifier.height(40.dp))
+
+ // Header Section
+ Box(
+ modifier = Modifier
+ .size(110.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surface)
+ .background(
+ Brush.linearGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.primaryContainer,
+ MaterialTheme.colorScheme.tertiaryContainer
+ )
+ ),
+ alpha = 0.5f
+ ),
+ contentAlignment = Alignment.Center
) {
Text(
- text = "Supported Formats",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold
+ text = "📚",
+ fontSize = 56.sp
)
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceEvenly
- ) {
- FormatChip(
- emoji = "📕",
- label = "PDF",
- color = Color(0xFFE53935)
- )
- FormatChip(
- emoji = "📘",
- label = "DOCX",
- color = Color(0xFF1976D2)
- )
- FormatChip(
- emoji = "📗",
- label = "XLSX",
- color = Color(0xFF388E3C)
- )
- }
}
- }
- Spacer(modifier = Modifier.height(32.dp))
-
- // Open file button
- Button(
- onClick = {
- filePickerLauncher.launch(
- arrayOf(
- "application/pdf",
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- "application/msword",
- "application/vnd.ms-excel"
- )
- )
- },
- modifier = Modifier
- .fillMaxWidth()
- .height(56.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primary
- )
- ) {
+ Spacer(modifier = Modifier.height(24.dp))
+
Text(
- text = "Open Document",
- style = MaterialTheme.typography.titleMedium
+ text = "Simple Reader",
+ style = MaterialTheme.typography.displaySmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onBackground
)
- }
- Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Your document companion",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 8.dp)
+ )
- // Quick open buttons for specific types
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- OutlinedButton(
- onClick = {
- filePickerLauncher.launch(arrayOf("application/pdf"))
- },
- modifier = Modifier.weight(1f)
- ) {
- Text("📕 PDF")
- }
+ Spacer(modifier = Modifier.height(48.dp))
- OutlinedButton(
+ // Main Action Button
+ ElevatedCard(
onClick = {
filePickerLauncher.launch(
arrayOf(
+ "application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- "application/msword"
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/msword",
+ "application/vnd.ms-excel"
)
)
},
- modifier = Modifier.weight(1f)
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp),
+ elevation = CardDefaults.elevatedCardElevation(defaultElevation = 6.dp),
+ colors = CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ )
) {
- Text("📘 Word")
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 24.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column {
+ Text(
+ text = "Open Document",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ Text(
+ text = "Browse storage...",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
+ )
+ }
+ Text(
+ text = "📂",
+ fontSize = 32.sp
+ )
+ }
+
}
- OutlinedButton(
- onClick = {
- filePickerLauncher.launch(
- arrayOf(
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- "application/vnd.ms-excel"
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = "Quick Access",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 12.dp),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ // Quick Action Grid
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ QuickActionCard(
+ emoji = "📕",
+ label = "PDF",
+ color = MaterialTheme.colorScheme.errorContainer,
+ textColor = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.weight(1f),
+ onClick = { filePickerLauncher.launch(arrayOf("application/pdf")) }
+ )
+
+ QuickActionCard(
+ emoji = "📘",
+ label = "Word",
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ textColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.weight(1f),
+ onClick = {
+ filePickerLauncher.launch(
+ arrayOf(
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/msword"
+ )
+ )
+ }
+ )
+
+ QuickActionCard(
+ emoji = "📗",
+ label = "Excel",
+ color = MaterialTheme.colorScheme.tertiaryContainer,
+ textColor = MaterialTheme.colorScheme.onTertiaryContainer,
+ modifier = Modifier.weight(1f),
+ onClick = {
+ filePickerLauncher.launch(
+ arrayOf(
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/vnd.ms-excel"
+ )
)
+ }
+ )
+ }
+
+ Spacer(modifier = Modifier.height(48.dp))
+
+ // Footer
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .background(
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ RoundedCornerShape(12.dp)
)
- },
- modifier = Modifier.weight(1f)
+ .padding(16.dp)
) {
- Text("📗 Excel")
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("💡")
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = "Tip: You can widely open documents directly from your file manager.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
}
- }
- Spacer(modifier = Modifier.height(32.dp))
+ Spacer(modifier = Modifier.height(24.dp))
- Text(
- text = "Or use \"Open With\" from your file manager",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center
- )
+ Text(
+ text = "Made by @dqez",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
+ modifier = Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .clickable { uriHandler.openUri("https://github.com/dqez") }
+ .padding(8.dp)
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+ }
}
}
}
@Composable
-private fun FormatChip(
+fun QuickActionCard(
emoji: String,
label: String,
color: Color,
- modifier: Modifier = Modifier
+ textColor: Color,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit
) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- modifier = modifier
+ Card(
+ onClick = onClick,
+ modifier = modifier.height(100.dp),
+ colors = CardDefaults.cardColors(containerColor = color),
+ shape = RoundedCornerShape(16.dp)
) {
- Text(
- text = emoji,
- fontSize = 32.sp
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = label,
- style = MaterialTheme.typography.labelMedium,
- color = color,
- fontWeight = FontWeight.Medium
- )
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(text = emoji, fontSize = 28.sp)
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = textColor
+ )
+ }
}
}
diff --git a/app/src/main/java/com/zeq/simple/reader/ui/screens/OfficeViewerScreen.kt b/app/src/main/java/com/zeq/simple/reader/ui/screens/OfficeViewerScreen.kt
index 0f04d68..7e7a2f5 100644
--- a/app/src/main/java/com/zeq/simple/reader/ui/screens/OfficeViewerScreen.kt
+++ b/app/src/main/java/com/zeq/simple/reader/ui/screens/OfficeViewerScreen.kt
@@ -4,44 +4,75 @@ import android.annotation.SuppressLint
import android.view.ViewGroup
import android.webkit.WebSettings
import android.webkit.WebView
+import androidx.compose.foundation.gestures.rememberTransformableState
+import androidx.compose.foundation.gestures.transformable
import androidx.compose.foundation.layout.Box
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.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
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.geometry.Offset
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
+import coil.compose.AsyncImage
import com.zeq.simple.reader.model.DocumentState
import com.zeq.simple.reader.model.DocumentType
import com.zeq.simple.reader.viewmodel.OfficeViewModel
/**
- * Office Document Viewer Screen for DOCX and XLSX files.
- * Renders parsed HTML content in a secure WebView.
+ * Office Document and Image Viewer Screen.
+ * Supports DOCX, XLSX files (rendered as HTML in WebView) and image files.
*
- * Security measures:
+ * Features:
+ * - Text search with highlighting
+ * - Navigate between search results
+ * - JavaScript enabled for XLSX tab switching
+ *
+ * Security measures for WebView:
* - JavaScript enabled only for XLSX tab switching (no external scripts)
* - File access disabled
* - Content access disabled
* - No network access
+ *
+ * Image viewer features:
+ * - Pinch to zoom
+ * - Pan to move
+ * - Double tap to reset
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -51,6 +82,9 @@ fun OfficeViewerScreen(
modifier: Modifier = Modifier
) {
val state by viewModel.state.collectAsState()
+ var showSearch by remember { mutableStateOf(false) }
+ var searchQuery by remember { mutableStateOf("") }
+ var webViewInstance by remember { mutableStateOf(null) }
Scaffold(
topBar = {
@@ -72,6 +106,21 @@ fun OfficeViewerScreen(
)
}
}
+ // is DocumentState.ImageLoaded -> {
+ // Column {
+ // Text(
+ // text = s.fileName,
+ // maxLines = 1,
+ // overflow = TextOverflow.Ellipsis,
+ // style = MaterialTheme.typography.titleMedium
+ // )
+ // Text(
+ // text = s.mimeType ?: "Image File",
+ // style = MaterialTheme.typography.bodySmall,
+ // color = MaterialTheme.colorScheme.onSurfaceVariant
+ // )
+ // }
+ // }
else -> Text("Document Viewer")
}
},
@@ -83,6 +132,17 @@ fun OfficeViewerScreen(
)
}
},
+ actions = {
+ // Show search icon only for Office documents
+ if (state is DocumentState.OfficeLoaded) {
+ IconButton(onClick = { showSearch = !showSearch }) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = "Search"
+ )
+ }
+ }
+ },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
@@ -100,11 +160,56 @@ fun OfficeViewerScreen(
LoadingContent()
}
is DocumentState.OfficeLoaded -> {
- SecureWebView(
- htmlContent = s.htmlContent,
- enableJavaScript = s.documentType == DocumentType.XLSX
- )
+ Column(modifier = Modifier.fillMaxSize()) {
+ // Search bar
+ if (showSearch) {
+ SearchBar(
+ query = searchQuery,
+ onQueryChange = { newQuery ->
+ searchQuery = newQuery
+ webViewInstance?.let { webView ->
+ performSearch(webView, newQuery)
+ }
+ },
+ onClose = {
+ showSearch = false
+ searchQuery = ""
+ webViewInstance?.let { webView ->
+ clearSearch(webView)
+ }
+ },
+ onNext = {
+ webViewInstance?.let { webView ->
+ findNext(webView, forward = true)
+ }
+ },
+ onPrevious = {
+ webViewInstance?.let { webView ->
+ findNext(webView, forward = false)
+ }
+ }
+ )
+ }
+
+ // WebView
+ SecureWebView(
+ htmlContent = s.htmlContent,
+ enableJavaScript = s.documentType == DocumentType.XLSX,
+ onWebViewCreated = { webView ->
+ webViewInstance = webView
+ },
+ modifier = Modifier
+ .fillMaxSize()
+ .weight(1f)
+ )
+ }
}
+ // is DocumentState.ImageLoaded -> {
+ // ZoomableImageViewer(
+ // uri = s.uri,
+ // contentDescription = s.fileName
+ // )
+ // }
is DocumentState.Error -> {
ErrorContent(message = s.message)
}
@@ -121,6 +226,7 @@ fun OfficeViewerScreen(
private fun SecureWebView(
htmlContent: String,
enableJavaScript: Boolean,
+ onWebViewCreated: (WebView) -> Unit = {},
modifier: Modifier = Modifier
) {
AndroidView(
@@ -168,6 +274,9 @@ private fun SecureWebView(
// Set background color
setBackgroundColor(android.graphics.Color.WHITE)
+
+ // Notify that WebView is created
+ onWebViewCreated(this)
}
},
update = { webView ->
@@ -184,6 +293,108 @@ private fun SecureWebView(
)
}
+@Composable
+private fun SearchBar(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ onClose: () -> Unit,
+ onNext: () -> Unit,
+ onPrevious: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ modifier = modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 8.dp),
+ placeholder = { Text("Search in document...") },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ imeAction = ImeAction.Search
+ ),
+ keyboardActions = KeyboardActions(
+ onSearch = {
+ keyboardController?.hide()
+ onNext()
+ }
+ )
+ )
+
+ // Previous result
+ IconButton(
+ onClick = onPrevious,
+ enabled = query.isNotEmpty()
+ ) {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowUp,
+ contentDescription = "Previous"
+ )
+ }
+
+ // Next result
+ IconButton(
+ onClick = onNext,
+ enabled = query.isNotEmpty()
+ ) {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = "Next"
+ )
+ }
+
+ // Close search
+ IconButton(onClick = onClose) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Close search"
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Performs text search in WebView using JavaScript.
+ * Highlights all matches with yellow background.
+ */
+private fun performSearch(webView: WebView, query: String) {
+ if (query.isEmpty()) {
+ clearSearch(webView)
+ return
+ }
+
+ // Use WebView's built-in find functionality (API level 16+)
+ @Suppress("DEPRECATION")
+ webView.findAllAsync(query)
+}
+
+/**
+ * Navigate to next/previous search result.
+ */
+private fun findNext(webView: WebView, forward: Boolean) {
+ webView.findNext(forward)
+}
+
+/**
+ * Clears all search highlights.
+ */
+private fun clearSearch(webView: WebView) {
+ webView.clearMatches()
+}
+
@Composable
private fun LoadingContent(modifier: Modifier = Modifier) {
Box(
@@ -237,10 +448,53 @@ private fun ErrorContent(
}
}
+@Composable
+private fun ZoomableImageViewer(
+ uri: android.net.Uri,
+ contentDescription: String,
+ modifier: Modifier = Modifier
+) {
+ var scale by remember { mutableStateOf(1f) }
+ var offset by remember { mutableStateOf(Offset.Zero) }
+
+ val state = rememberTransformableState { zoomChange, offsetChange, _ ->
+ scale = (scale * zoomChange).coerceIn(0.5f, 5f)
+
+ val maxX = (scale - 1) * 1000f
+ val maxY = (scale - 1) * 1000f
+ offset = Offset(
+ x = (offset.x + offsetChange.x).coerceIn(-maxX, maxX),
+ y = (offset.y + offsetChange.y).coerceIn(-maxY, maxY)
+ )
+ }
+
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .transformable(state = state),
+ contentAlignment = Alignment.Center
+ ) {
+ AsyncImage(
+ model = uri,
+ contentDescription = contentDescription,
+ modifier = Modifier
+ .fillMaxSize()
+ .graphicsLayer(
+ scaleX = scale,
+ scaleY = scale,
+ translationX = offset.x,
+ translationY = offset.y
+ ),
+ contentScale = ContentScale.Fit
+ )
+ }
+}
+
private fun getDocumentTypeLabel(documentType: DocumentType): String {
return when (documentType) {
DocumentType.DOCX -> "Word Document"
DocumentType.XLSX -> "Excel Spreadsheet"
+ // DocumentType.IMAGE -> "Image File"
else -> "Document"
}
}
diff --git a/app/src/main/java/com/zeq/simple/reader/ui/screens/PdfViewerScreen.kt b/app/src/main/java/com/zeq/simple/reader/ui/screens/PdfViewerScreen.kt
index d34f3bd..c49750e 100644
--- a/app/src/main/java/com/zeq/simple/reader/ui/screens/PdfViewerScreen.kt
+++ b/app/src/main/java/com/zeq/simple/reader/ui/screens/PdfViewerScreen.kt
@@ -169,25 +169,54 @@ private fun PdfPageList(
onPageVisible: (Int) -> Unit,
modifier: Modifier = Modifier
) {
- LazyColumn(
- state = listState,
- contentPadding = PaddingValues(vertical = 8.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = modifier.fillMaxSize()
- ) {
- itemsIndexed(
- items = pages,
- key = { index, _ -> index }
- ) { index, page ->
- // Trigger rendering when page becomes visible
- LaunchedEffect(index) {
- onPageVisible(index)
+ // Global zoom state for all pages
+ var scale by remember { mutableFloatStateOf(1f) }
+ var offsetX by remember { mutableFloatStateOf(0f) }
+ var offsetY by remember { mutableFloatStateOf(0f) }
+
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .pointerInput(Unit) {
+ detectTransformGestures { _, pan, zoom, _ ->
+ scale = (scale * zoom).coerceIn(1f, 3f)
+ if (scale > 1f) {
+ offsetX += pan.x
+ offsetY += pan.y
+ } else {
+ offsetX = 0f
+ offsetY = 0f
+ }
+ }
}
+ ) {
+ LazyColumn(
+ state = listState,
+ contentPadding = PaddingValues(vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier
+ .fillMaxSize()
+ .graphicsLayer(
+ scaleX = scale,
+ scaleY = scale,
+ translationX = offsetX,
+ translationY = offsetY
+ )
+ ) {
+ itemsIndexed(
+ items = pages,
+ key = { index, _ -> index }
+ ) { index, page ->
+ // Trigger rendering when page becomes visible
+ LaunchedEffect(index) {
+ onPageVisible(index)
+ }
- PdfPageItem(
- page = page,
- pageNumber = index + 1
- )
+ PdfPageItem(
+ page = page,
+ pageNumber = index + 1
+ )
+ }
}
}
}
@@ -198,10 +227,6 @@ private fun PdfPageItem(
pageNumber: Int,
modifier: Modifier = Modifier
) {
- var scale by remember { mutableFloatStateOf(1f) }
- var offsetX by remember { mutableFloatStateOf(0f) }
- var offsetY by remember { mutableFloatStateOf(0f) }
-
Card(
modifier = modifier
.fillMaxWidth()
@@ -218,19 +243,7 @@ private fun PdfPageItem(
} else {
0.707f // A4 aspect ratio fallback
}
- )
- .pointerInput(Unit) {
- detectTransformGestures { _, pan, zoom, _ ->
- scale = (scale * zoom).coerceIn(1f, 3f)
- if (scale > 1f) {
- offsetX += pan.x
- offsetY += pan.y
- } else {
- offsetX = 0f
- offsetY = 0f
- }
- }
- },
+ ),
contentAlignment = Alignment.Center
) {
when {
@@ -241,12 +254,7 @@ private fun PdfPageItem(
)
}
page.bitmap != null && !page.bitmap.isRecycled -> {
- BitmapImage(
- bitmap = page.bitmap,
- scale = scale,
- offsetX = offsetX,
- offsetY = offsetY
- )
+ BitmapImage(bitmap = page.bitmap)
}
else -> {
// Placeholder while loading
@@ -271,23 +279,13 @@ private fun PdfPageItem(
@Composable
private fun BitmapImage(
bitmap: Bitmap,
- scale: Float,
- offsetX: Float,
- offsetY: Float,
modifier: Modifier = Modifier
) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "PDF Page",
contentScale = ContentScale.Fit,
- modifier = modifier
- .fillMaxSize()
- .graphicsLayer(
- scaleX = scale,
- scaleY = scale,
- translationX = offsetX,
- translationY = offsetY
- )
+ modifier = modifier.fillMaxSize()
)
}
diff --git a/img.png b/img.png
deleted file mode 100644
index cb25ab0..0000000
Binary files a/img.png and /dev/null differ
diff --git a/poster.png b/poster.png
index 8757ff0..318ee1d 100644
Binary files a/poster.png and b/poster.png differ