diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..037f643
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,33 @@
+---
+name: Bug Report
+about: Report a bug to help us improve FastMask
+title: '[Bug] '
+labels: bug
+assignees: ''
+---
+
+## Describe the Bug
+A clear and concise description of what the bug is.
+
+## Steps to Reproduce
+1. Go to '...'
+2. Tap on '...'
+3. Scroll to '...'
+4. See error
+
+## Expected Behavior
+What you expected to happen.
+
+## Actual Behavior
+What actually happened.
+
+## Screenshots
+If applicable, add screenshots to help explain your problem.
+
+## Environment
+- **Device**: [e.g., Pixel 7, Samsung Galaxy S23]
+- **Android Version**: [e.g., Android 14]
+- **App Version**: [e.g., 1.0.0]
+
+## Additional Context
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..b42ed5b
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: true
+contact_links:
+ - name: Documentation
+ url: https://github.com/pawelorzech/FastMask#readme
+ about: Check the README for setup and usage instructions
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..742f780
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,25 @@
+---
+name: Feature Request
+about: Suggest a new feature for FastMask
+title: '[Feature] '
+labels: enhancement
+assignees: ''
+---
+
+## Problem Statement
+A clear description of the problem or need. Ex. "I'm always frustrated when..."
+
+## Proposed Solution
+Describe the solution you'd like.
+
+## Alternatives Considered
+Any alternative solutions or features you've considered.
+
+## Use Case
+Describe how you would use this feature.
+
+## Mockups / Examples
+If applicable, add mockups, sketches, or examples from other apps.
+
+## Additional Context
+Add any other context about the feature request here.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..80e85c4
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,36 @@
+## Description
+Brief description of the changes in this PR.
+
+## Type of Change
+- [ ] Bug fix (non-breaking change that fixes an issue)
+- [ ] New feature (non-breaking change that adds functionality)
+- [ ] Breaking change (fix or feature that would cause existing functionality to change)
+- [ ] Documentation update
+
+## Related Issues
+Fixes #(issue number)
+
+## Changes Made
+- Change 1
+- Change 2
+- Change 3
+
+## Testing
+Describe the tests you ran to verify your changes:
+- [ ] Tested on emulator (API level: )
+- [ ] Tested on physical device (model: )
+- [ ] Unit tests pass
+- [ ] UI looks correct in light and dark mode
+
+## Screenshots (if applicable)
+| Before | After |
+|--------|-------|
+| | |
+
+## Checklist
+- [ ] My code follows the project's style guidelines
+- [ ] I have performed a self-review of my code
+- [ ] I have commented my code where necessary
+- [ ] I have updated the documentation if needed
+- [ ] My changes generate no new warnings
+- [ ] Any dependent changes have been merged and published
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..5fb64a2
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,124 @@
+# Contributing to FastMask
+
+First off, thank you for considering contributing to FastMask! It's people like you that make FastMask a great tool for managing Fastmail masked emails.
+
+## Code of Conduct
+
+By participating in this project, you agree to maintain a respectful and inclusive environment for everyone.
+
+## How Can I Contribute?
+
+### Reporting Bugs
+
+Before creating bug reports, please check existing issues to avoid duplicates. When you create a bug report, include as many details as possible:
+
+- **Use a clear and descriptive title**
+- **Describe the exact steps to reproduce the problem**
+- **Describe the behavior you observed and what you expected**
+- **Include your Android version and device model**
+- **Include screenshots if applicable**
+
+### Suggesting Features
+
+Feature suggestions are welcome! Please:
+
+- **Use a clear and descriptive title**
+- **Provide a detailed description of the proposed feature**
+- **Explain why this feature would be useful**
+- **Include mockups or examples if possible**
+
+### Pull Requests
+
+1. Fork the repository
+2. Create a feature branch from `main`:
+ ```bash
+ git checkout -b feature/your-feature-name
+ ```
+3. Make your changes
+4. Test your changes thoroughly
+5. Commit with a meaningful message:
+ ```bash
+ git commit -m "Add: description of your changes"
+ ```
+6. Push to your fork:
+ ```bash
+ git push origin feature/your-feature-name
+ ```
+7. Open a Pull Request
+
+## Development Setup
+
+### Prerequisites
+
+- [Android Studio](https://developer.android.com/studio) (latest stable version)
+- JDK 17 or higher
+- Android SDK with API 34
+
+### Building the Project
+
+```bash
+# Clone your fork
+git clone https://github.com/YOUR_USERNAME/FastMask.git
+cd FastMask
+
+# Open in Android Studio or build via command line
+./gradlew assembleDebug
+```
+
+### Running Tests
+
+```bash
+# Run unit tests
+./gradlew test
+
+# Run instrumented tests (requires emulator or device)
+./gradlew connectedAndroidTest
+```
+
+## Code Style
+
+### Kotlin
+
+- Follow [Kotlin coding conventions](https://kotlinlang.org/docs/coding-conventions.html)
+- Use meaningful names for variables, functions, and classes
+- Keep functions small and focused
+- Use Kotlin idioms (scope functions, extension functions, etc.)
+
+### Compose
+
+- Keep composables small and reusable
+- Use `remember` and `derivedStateOf` appropriately
+- Follow the [Compose API guidelines](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/compose/docs/compose-api-guidelines.md)
+
+### Architecture
+
+- Follow Clean Architecture principles
+- Keep the data, domain, and UI layers separate
+- Use use cases for business logic
+- ViewModels should only contain UI state logic
+
+### Commit Messages
+
+Use clear, descriptive commit messages:
+
+- `Add: new feature description`
+- `Fix: bug description`
+- `Update: what was changed`
+- `Refactor: what was refactored`
+- `Docs: documentation changes`
+
+## Project Structure
+
+```
+app/src/main/java/com/fastmask/
+├── data/ # Data layer (API, storage, repository implementations)
+├── domain/ # Domain layer (models, repository interfaces, use cases)
+├── di/ # Hilt dependency injection modules
+└── ui/ # UI layer (screens, viewmodels, components, theme)
+```
+
+## Questions?
+
+Feel free to open an issue with the `question` label if you have any questions about contributing.
+
+Thank you for contributing!
diff --git a/README.md b/README.md
index f24258a..e1e3406 100644
--- a/README.md
+++ b/README.md
@@ -1,96 +1,217 @@
-# FastMask
+
+
+
-A native Android app for managing Fastmail masked emails. Create, view, edit, and manage your masked email addresses directly from your Android device.
+FastMask
+
+
+ Native Android app for managing Fastmail masked emails
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Features •
+ Screenshots •
+ Installation •
+ Setup •
+ Tech Stack •
+ Architecture •
+ Contributing
+
+
+---
+
+## About
+
+FastMask is a native Android application that lets you manage your [Fastmail](https://www.fastmail.com) masked email addresses directly from your phone. Masked emails are disposable addresses that forward to your real inbox, helping you protect your privacy and reduce spam.
## Features
-- **View Masked Emails** - Browse all your Fastmail masked email addresses in a clean, organized list
-- **Create New Masks** - Generate new masked email addresses with optional descriptions and domain associations
-- **Enable/Disable** - Toggle masked emails on or off without deleting them
-- **Edit Details** - Update description, associated domain, and URL for any masked email
-- **Copy to Clipboard** - Quickly copy email addresses with one tap
-- **Delete** - Remove masked emails you no longer need
-- **Search** - Filter your masked emails to find what you need
-- **Material You** - Modern Material 3 design with dynamic theming support
+| Feature | Description |
+|---------|-------------|
+| **View All Masks** | Browse your masked emails in a clean, searchable list |
+| **Create New** | Generate new masked addresses with custom descriptions |
+| **Enable/Disable** | Toggle masks on or off without deleting them |
+| **Edit Details** | Update description, domain, and URL associations |
+| **Quick Copy** | One-tap copy to clipboard |
+| **Delete** | Remove masks you no longer need |
+| **Search & Filter** | Find specific masks instantly |
+| **Material You** | Dynamic theming that adapts to your wallpaper |
## Screenshots
-*Coming soon*
+
+ Screenshots coming soon
+
+
+
+
+## Installation
+
+### Download APK
+
+1. Go to the [Releases](https://github.com/pawelorzech/FastMask/releases/latest) page
+2. Download the latest APK file
+3. Enable "Install from unknown sources" if prompted
+4. Install the APK
+
+### Build from Source
+
+```bash
+# Clone the repository
+git clone https://github.com/pawelorzech/FastMask.git
+cd FastMask
+
+# Build debug APK
+./gradlew assembleDebug
+
+# Or build release APK
+./gradlew assembleRelease
+```
+
+The APK will be generated in `app/build/outputs/apk/`
## Requirements
- Android 8.0 (API 26) or higher
- Fastmail account with API access
-## Installation
-
-### From GitHub Releases
-
-1. Download the latest APK from the [Releases](https://github.com/pawelorzech/FastMask/releases) page
-2. Enable "Install from unknown sources" for your browser or file manager
-3. Open the APK file to install
-
-### Build from Source
-
-1. Clone the repository:
- ```bash
- git clone https://github.com/pawelorzech/FastMask.git
- ```
-
-2. Open the project in Android Studio
-
-3. Build and run on your device or emulator
-
## Setup
-1. Create a Fastmail API token:
- - Log in to [Fastmail](https://www.fastmail.com)
- - Go to **Settings** → **Privacy & Security** → **Integrations** → **API tokens**
- - Click **New API token**
- - Give it a name (e.g., "FastMask")
- - Select the scope: **Masked Email** (read/write)
- - Copy the generated token
+### 1. Create a Fastmail API Token
-2. Open FastMask and paste your API token to log in
+1. Log in to [Fastmail](https://www.fastmail.com)
+2. Navigate to **Settings** → **Privacy & Security** → **Integrations** → **API tokens**
+3. Click **New API token**
+4. Name it (e.g., "FastMask")
+5. Select scope: **Masked Email** (read/write)
+6. Copy the generated token
+
+### 2. Log in to FastMask
+
+1. Open the app
+2. Paste your API token
+3. Tap "Log in"
+
+Your token is stored securely using Android's EncryptedSharedPreferences.
## Tech Stack
-- **Kotlin** - 100% Kotlin codebase
-- **Jetpack Compose** - Modern declarative UI
-- **Material 3** - Latest Material Design components
-- **Hilt** - Dependency injection
-- **Coroutines & Flow** - Asynchronous programming
-- **Retrofit + OkHttp** - Network communication
-- **Kotlinx Serialization** - JSON parsing
-- **JMAP Protocol** - Fastmail's native API
+| Category | Technology |
+|----------|------------|
+| **Language** | [Kotlin](https://kotlinlang.org/) 100% |
+| **UI Framework** | [Jetpack Compose](https://developer.android.com/jetpack/compose) |
+| **Design System** | [Material 3](https://m3.material.io/) with dynamic theming |
+| **DI** | [Hilt](https://dagger.dev/hilt/) |
+| **Networking** | [Retrofit](https://square.github.io/retrofit/) + [OkHttp](https://square.github.io/okhttp/) |
+| **Serialization** | [Kotlinx Serialization](https://github.com/Kotlin/kotlinx.serialization) |
+| **Async** | [Coroutines](https://kotlinlang.org/docs/coroutines-overview.html) + [Flow](https://kotlinlang.org/docs/flow.html) |
+| **API Protocol** | [JMAP](https://jmap.io/) (Fastmail's native protocol) |
## Architecture
-The app follows Clean Architecture principles with MVVM pattern:
+The app follows **Clean Architecture** principles with **MVVM** pattern:
```
app/
-├── data/ # Data layer (API, repositories)
-├── domain/ # Business logic (use cases, models)
-├── di/ # Dependency injection modules
-└── ui/ # Presentation layer (screens, viewmodels)
+├── data/ # Data layer
+│ ├── api/ # JMAP API service & models
+│ ├── local/ # Secure token storage
+│ └── repository/ # Repository implementations
+│
+├── domain/ # Business logic layer
+│ ├── model/ # Domain models
+│ ├── repository/ # Repository interfaces
+│ └── usecase/ # Use cases
+│
+├── di/ # Dependency injection modules
+│
+└── ui/ # Presentation layer
+ ├── auth/ # Login screen
+ ├── list/ # Masked email list
+ ├── create/ # Create new mask
+ ├── detail/ # View/edit mask details
+ ├── components/ # Reusable UI components
+ ├── navigation/ # Navigation setup
+ └── theme/ # Material 3 theming
```
-## Privacy
+## Privacy & Security
-- Your API token is stored securely using Android's EncryptedSharedPreferences
-- The app communicates directly with Fastmail's API - no third-party servers
-- No analytics or tracking
+- **Local Storage Only**: Your API token is stored locally using Android's [EncryptedSharedPreferences](https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences)
+- **Direct API Communication**: The app communicates directly with Fastmail's servers - no intermediary servers
+- **No Tracking**: Zero analytics, telemetry, or data collection
+- **Open Source**: Full source code available for audit
## Contributing
-Contributions are welcome! Please feel free to submit a Pull Request.
+Contributions are welcome! Here's how you can help:
+
+1. **Fork** the repository
+2. **Create** a feature branch (`git checkout -b feature/amazing-feature`)
+3. **Commit** your changes (`git commit -m 'Add amazing feature'`)
+4. **Push** to the branch (`git push origin feature/amazing-feature`)
+5. **Open** a Pull Request
+
+### Development Setup
+
+1. Install [Android Studio](https://developer.android.com/studio) (latest stable)
+2. Clone the repository
+3. Open the project in Android Studio
+4. Sync Gradle and run on an emulator or device
+
+### Code Style
+
+- Follow [Kotlin coding conventions](https://kotlinlang.org/docs/coding-conventions.html)
+- Use meaningful commit messages
+- Write tests for new features when applicable
+
+## Roadmap
+
+- [ ] Add screenshots to README
+- [ ] Biometric authentication option
+- [ ] Widget for quick mask creation
+- [ ] Export/import functionality
+- [ ] Dark/light mode toggle
+- [ ] Localization support
## License
-This project is open source. See the [LICENSE](LICENSE) file for details.
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Acknowledgments
-- [Fastmail](https://www.fastmail.com) for their excellent JMAP API
-- [JMAP](https://jmap.io) specification for masked emails
+- [Fastmail](https://www.fastmail.com) for their excellent email service and JMAP API
+- [JMAP](https://jmap.io/) for the open standard specification
+- The Android and Kotlin communities for amazing tools and libraries
+
+---
+
+
+ Made with Kotlin and Jetpack Compose
+
+
+
+ Report Bug •
+ Request Feature
+
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..599a84d
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,78 @@
+# Security Policy
+
+## Reporting a Vulnerability
+
+The FastMask team takes security seriously. We appreciate your efforts to responsibly disclose your findings.
+
+### How to Report
+
+If you discover a security vulnerability, please report it by:
+
+1. **Opening a private security advisory** on GitHub:
+ - Go to the [Security tab](https://github.com/pawelorzech/FastMask/security/advisories)
+ - Click "New draft security advisory"
+ - Provide details about the vulnerability
+
+2. **Or emailing directly** (if available in the repository owner's profile)
+
+### What to Include
+
+- Description of the vulnerability
+- Steps to reproduce
+- Potential impact
+- Suggested fix (if any)
+
+### Response Timeline
+
+- **Acknowledgment**: Within 48 hours
+- **Initial assessment**: Within 1 week
+- **Resolution timeline**: Depends on severity, typically 30-90 days
+
+## Security Measures in FastMask
+
+### Data Storage
+
+- API tokens are stored using Android's [EncryptedSharedPreferences](https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences)
+- Encryption uses AES-256-GCM for values and AES-256-SIV for keys
+- No sensitive data is stored in plain text
+
+### Network Security
+
+- All communication with Fastmail uses HTTPS/TLS
+- Certificate pinning is recommended for production builds
+- No data is sent to third-party servers
+
+### Privacy
+
+- No analytics or tracking
+- No data collection
+- Direct communication with Fastmail API only
+
+## Supported Versions
+
+| Version | Supported |
+| ------- | ------------------ |
+| 1.x | :white_check_mark: |
+
+## Best Practices for Users
+
+1. **Protect your API token**: Treat it like a password
+2. **Use device security**: Enable screen lock on your device
+3. **Keep the app updated**: Install updates for security fixes
+4. **Review permissions**: The app only requests necessary permissions
+
+## Scope
+
+The following are **in scope** for security reports:
+
+- Authentication and authorization issues
+- Data leakage or exposure
+- Cryptographic weaknesses
+- API security issues
+
+The following are **out of scope**:
+
+- Social engineering attacks
+- Physical attacks on user devices
+- Denial of service attacks
+- Issues in third-party dependencies (report to upstream)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 75e34a1..83430d7 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -70,16 +70,24 @@ android {
dependencies {
// Core Android
implementation("androidx.core:core-ktx:1.12.0")
+ implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
- implementation("androidx.activity:activity-compose:1.8.2")
+ implementation("androidx.activity:activity-compose:1.9.0")
// Compose
implementation(platform("androidx.compose:compose-bom:2024.09.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.ui:ui-text-google-fonts:1.6.0")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
+ implementation("androidx.compose.animation:animation")
+
+ // Large screen support
+ implementation("androidx.compose.material3.adaptive:adaptive:1.0.0")
+ implementation("androidx.compose.material3.adaptive:adaptive-layout:1.0.0")
+ implementation("androidx.compose.material3.adaptive:adaptive-navigation:1.0.0")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.6")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index baa940b..2785683 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -19,7 +19,7 @@
+ android:theme="@style/Theme.FastMask.Splash">
diff --git a/app/src/main/java/com/fastmask/MainActivity.kt b/app/src/main/java/com/fastmask/MainActivity.kt
index 1aea624..d31fe89 100644
--- a/app/src/main/java/com/fastmask/MainActivity.kt
+++ b/app/src/main/java/com/fastmask/MainActivity.kt
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.navigation.compose.rememberNavController
import com.fastmask.domain.repository.AuthRepository
import com.fastmask.ui.navigation.FastMaskNavHost
@@ -22,10 +23,23 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var authRepository: AuthRepository
+ private var isReady = false
+
override fun onCreate(savedInstanceState: Bundle?) {
+ val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
+
+ splashScreen.setKeepOnScreenCondition { !isReady }
+
enableEdgeToEdge()
+ val startDestination = if (authRepository.isLoggedIn()) {
+ NavRoutes.EMAIL_LIST
+ } else {
+ NavRoutes.LOGIN
+ }
+ isReady = true
+
setContent {
FastMaskTheme {
Surface(
@@ -33,11 +47,6 @@ class MainActivity : ComponentActivity() {
color = MaterialTheme.colorScheme.background
) {
val navController = rememberNavController()
- val startDestination = if (authRepository.isLoggedIn()) {
- NavRoutes.EMAIL_LIST
- } else {
- NavRoutes.LOGIN
- }
FastMaskNavHost(
navController = navController,
diff --git a/app/src/main/java/com/fastmask/ui/components/MaskedEmailCard.kt b/app/src/main/java/com/fastmask/ui/components/MaskedEmailCard.kt
index 6c3301a..238fb13 100644
--- a/app/src/main/java/com/fastmask/ui/components/MaskedEmailCard.kt
+++ b/app/src/main/java/com/fastmask/ui/components/MaskedEmailCard.kt
@@ -1,6 +1,11 @@
package com.fastmask.ui.components
+import androidx.compose.animation.AnimatedContentScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -8,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
@@ -21,57 +27,102 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.fastmask.domain.model.EmailState
import com.fastmask.domain.model.MaskedEmail
-import com.fastmask.ui.theme.DeletedRed
-import com.fastmask.ui.theme.DisabledGray
-import com.fastmask.ui.theme.EnabledGreen
-import com.fastmask.ui.theme.PendingOrange
+import com.fastmask.ui.theme.FastMaskStatusColors
+@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun MaskedEmailCard(
maskedEmail: MaskedEmail,
onClick: () -> Unit,
+ sharedTransitionScope: SharedTransitionScope,
+ animatedContentScope: AnimatedContentScope,
modifier: Modifier = Modifier
) {
- Card(
- modifier = modifier
- .fillMaxWidth()
- .clickable(onClick = onClick),
- elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
- ) {
- Row(
- modifier = Modifier
+ val haptic = LocalHapticFeedback.current
+ val statusColors = FastMaskStatusColors.current
+
+ val stateDescription = when (maskedEmail.state) {
+ EmailState.ENABLED -> "Enabled"
+ EmailState.DISABLED -> "Disabled"
+ EmailState.DELETED -> "Deleted"
+ EmailState.PENDING -> "Pending"
+ }
+
+ with(sharedTransitionScope) {
+ Card(
+ modifier = modifier
.fillMaxWidth()
- .padding(16.dp),
- verticalAlignment = Alignment.CenterVertically
+ .sharedBounds(
+ sharedContentState = rememberSharedContentState(key = "card-${maskedEmail.id}"),
+ animatedVisibilityScope = animatedContentScope
+ )
+ .clickable {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onClick()
+ }
+ .semantics {
+ contentDescription = "${maskedEmail.displayName}, ${maskedEmail.email}, $stateDescription"
+ },
+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow
+ )
) {
- StatusIcon(state = maskedEmail.state)
- Spacer(modifier = Modifier.width(16.dp))
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = maskedEmail.displayName,
- style = MaterialTheme.typography.titleMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ StatusIcon(
+ state = maskedEmail.state,
+ modifier = Modifier.sharedElement(
+ state = rememberSharedContentState(key = "icon-${maskedEmail.id}"),
+ animatedVisibilityScope = animatedContentScope
+ )
)
- Text(
- text = maskedEmail.email,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- maskedEmail.forDomain?.let { domain ->
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
Text(
- text = domain,
- style = MaterialTheme.typography.bodySmall,
+ text = maskedEmail.displayName,
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.sharedElement(
+ state = rememberSharedContentState(key = "title-${maskedEmail.id}"),
+ animatedVisibilityScope = animatedContentScope
+ )
+ )
+ Text(
+ text = maskedEmail.email,
+ style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
- overflow = TextOverflow.Ellipsis
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.sharedElement(
+ state = rememberSharedContentState(key = "email-${maskedEmail.id}"),
+ animatedVisibilityScope = animatedContentScope
+ )
)
+ maskedEmail.forDomain?.let { domain ->
+ Text(
+ text = domain,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
}
}
}
@@ -79,18 +130,31 @@ fun MaskedEmailCard(
}
@Composable
-private fun StatusIcon(state: EmailState) {
- val (icon, color) = when (state) {
- EmailState.ENABLED -> Icons.Default.Check to EnabledGreen
- EmailState.DISABLED -> Icons.Default.Close to DisabledGray
- EmailState.DELETED -> Icons.Default.Delete to DeletedRed
- EmailState.PENDING -> Icons.Default.HourglassEmpty to PendingOrange
+private fun StatusIcon(
+ state: EmailState,
+ modifier: Modifier = Modifier
+) {
+ val statusColors = FastMaskStatusColors.current
+
+ val (icon, colorPair) = when (state) {
+ EmailState.ENABLED -> Icons.Default.Check to statusColors.enabled
+ EmailState.DISABLED -> Icons.Default.Close to statusColors.disabled
+ EmailState.DELETED -> Icons.Default.Delete to statusColors.deleted
+ EmailState.PENDING -> Icons.Default.HourglassEmpty to statusColors.pending
}
- Icon(
- imageVector = icon,
- contentDescription = state.name,
- modifier = Modifier.size(24.dp),
- tint = color
- )
+ Box(
+ modifier = modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(colorPair.container),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = state.name,
+ modifier = Modifier.size(24.dp),
+ tint = colorPair.content
+ )
+ }
}
diff --git a/app/src/main/java/com/fastmask/ui/detail/MaskedEmailDetailScreen.kt b/app/src/main/java/com/fastmask/ui/detail/MaskedEmailDetailScreen.kt
index 282f684..405c8df 100644
--- a/app/src/main/java/com/fastmask/ui/detail/MaskedEmailDetailScreen.kt
+++ b/app/src/main/java/com/fastmask/ui/detail/MaskedEmailDetailScreen.kt
@@ -3,8 +3,16 @@ package com.fastmask.ui.detail
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
-import android.widget.Toast
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedContentScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.background
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
@@ -15,11 +23,15 @@ import androidx.compose.foundation.layout.padding
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.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -33,6 +45,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarDuration
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -43,40 +59,55 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.fastmask.domain.model.EmailState
import com.fastmask.ui.components.ErrorMessage
import com.fastmask.ui.components.LoadingIndicator
-import com.fastmask.ui.theme.DeletedRed
-import com.fastmask.ui.theme.DisabledGray
-import com.fastmask.ui.theme.EnabledGreen
-import com.fastmask.ui.theme.PendingOrange
+import com.fastmask.ui.theme.FastMaskStatusColors
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable
fun MaskedEmailDetailScreen(
onNavigateBack: () -> Unit,
+ sharedTransitionScope: SharedTransitionScope,
+ animatedContentScope: AnimatedContentScope,
viewModel: MaskedEmailDetailViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
var showDeleteDialog by remember { mutableStateOf(false) }
+ val snackbarHostState = remember { SnackbarHostState() }
+ val scope = rememberCoroutineScope()
+ val haptic = LocalHapticFeedback.current
LaunchedEffect(Unit) {
viewModel.events.collectLatest { event ->
when (event) {
is MaskedEmailDetailEvent.Updated -> {
- Toast.makeText(context, "Updated successfully", Toast.LENGTH_SHORT).show()
+ snackbarHostState.showSnackbar(
+ message = "Updated successfully",
+ duration = SnackbarDuration.Short
+ )
}
is MaskedEmailDetailEvent.Deleted -> {
- Toast.makeText(context, "Deleted successfully", Toast.LENGTH_SHORT).show()
+ snackbarHostState.showSnackbar(
+ message = "Deleted successfully",
+ duration = SnackbarDuration.Short
+ )
onNavigateBack()
}
}
@@ -114,61 +145,102 @@ fun MaskedEmailDetailScreen(
TopAppBar(
title = { Text("Email Details") },
navigationIcon = {
- IconButton(onClick = onNavigateBack) {
+ IconButton(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onNavigateBack()
+ },
+ modifier = Modifier.semantics {
+ contentDescription = "Navigate back"
+ }
+ ) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
- contentDescription = "Back"
+ contentDescription = null
)
}
},
actions = {
if (uiState.email != null) {
- IconButton(onClick = { showDeleteDialog = true }) {
+ IconButton(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ showDeleteDialog = true
+ },
+ modifier = Modifier.semantics {
+ contentDescription = "Delete email"
+ }
+ ) {
Icon(
imageVector = Icons.Default.Delete,
- contentDescription = "Delete",
+ contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer,
- titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
+ containerColor = MaterialTheme.colorScheme.surface,
+ titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
+ },
+ snackbarHost = {
+ SnackbarHost(hostState = snackbarHostState)
}
) { paddingValues ->
- when {
- uiState.isLoading && uiState.email == null -> {
- LoadingIndicator(
- modifier = Modifier.padding(paddingValues)
- )
- }
+ AnimatedContent(
+ targetState = Triple(uiState.isLoading && uiState.email == null, uiState.error != null && uiState.email == null, uiState.email != null),
+ transitionSpec = {
+ fadeIn() togetherWith fadeOut()
+ },
+ label = "content_state"
+ ) { (isLoading, hasError, hasEmail) ->
+ when {
+ isLoading -> {
+ LoadingIndicator(
+ modifier = Modifier.padding(paddingValues)
+ )
+ }
- uiState.error != null && uiState.email == null -> {
- ErrorMessage(
- message = uiState.error!!,
- onRetry = viewModel::loadEmail,
- modifier = Modifier.padding(paddingValues)
- )
- }
+ hasError -> {
+ ErrorMessage(
+ message = uiState.error!!,
+ onRetry = viewModel::loadEmail,
+ modifier = Modifier.padding(paddingValues)
+ )
+ }
- uiState.email != null -> {
- EmailDetailContent(
- uiState = uiState,
- onDescriptionChange = viewModel::onDescriptionChange,
- onForDomainChange = viewModel::onForDomainChange,
- onUrlChange = viewModel::onUrlChange,
- onToggleState = viewModel::toggleState,
- onSaveChanges = viewModel::saveChanges,
- modifier = Modifier.padding(paddingValues)
- )
+ hasEmail -> {
+ EmailDetailContent(
+ uiState = uiState,
+ onDescriptionChange = viewModel::onDescriptionChange,
+ onForDomainChange = viewModel::onForDomainChange,
+ onUrlChange = viewModel::onUrlChange,
+ onToggleState = viewModel::toggleState,
+ onSaveChanges = viewModel::saveChanges,
+ onCopyEmail = { email ->
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip = ClipData.newPlainText("Email", email)
+ clipboard.setPrimaryClip(clip)
+ scope.launch {
+ snackbarHostState.showSnackbar(
+ message = "Copied to clipboard",
+ duration = SnackbarDuration.Short
+ )
+ }
+ },
+ sharedTransitionScope = sharedTransitionScope,
+ animatedContentScope = animatedContentScope,
+ modifier = Modifier.padding(paddingValues)
+ )
+ }
}
}
}
}
+@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun EmailDetailContent(
uiState: MaskedEmailDetailUiState,
@@ -177,217 +249,291 @@ private fun EmailDetailContent(
onUrlChange: (String) -> Unit,
onToggleState: () -> Unit,
onSaveChanges: () -> Unit,
+ onCopyEmail: (String) -> Unit,
+ sharedTransitionScope: SharedTransitionScope,
+ animatedContentScope: AnimatedContentScope,
modifier: Modifier = Modifier
) {
val email = uiState.email!!
- val context = LocalContext.current
+ val statusColors = FastMaskStatusColors.current
+ val haptic = LocalHapticFeedback.current
- Column(
- modifier = modifier
- .fillMaxSize()
- .padding(16.dp)
- .verticalScroll(rememberScrollState())
- ) {
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceVariant
- )
+ val colorPair = when (email.state) {
+ EmailState.ENABLED -> statusColors.enabled
+ EmailState.DISABLED -> statusColors.disabled
+ EmailState.DELETED -> statusColors.deleted
+ EmailState.PENDING -> statusColors.pending
+ }
+
+ with(sharedTransitionScope) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState())
) {
- Column(
- modifier = Modifier.padding(16.dp)
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .sharedBounds(
+ sharedContentState = rememberSharedContentState(key = "card-${email.id}"),
+ animatedVisibilityScope = animatedContentScope
+ ),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow
+ )
) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
+ Column(
+ modifier = Modifier.padding(16.dp)
) {
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "Email Address",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ StatusIcon(
+ state = email.state,
+ modifier = Modifier.sharedElement(
+ state = rememberSharedContentState(key = "icon-${email.id}"),
+ animatedVisibilityScope = animatedContentScope
+ )
)
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = email.displayName,
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.sharedElement(
+ state = rememberSharedContentState(key = "title-${email.id}"),
+ animatedVisibilityScope = animatedContentScope
+ )
+ )
+ Text(
+ text = email.email,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.sharedElement(
+ state = rememberSharedContentState(key = "email-${email.id}"),
+ animatedVisibilityScope = animatedContentScope
+ )
+ )
+ }
+ IconButton(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onCopyEmail(email.email)
+ },
+ modifier = Modifier.semantics {
+ contentDescription = "Copy email address"
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.ContentCopy,
+ contentDescription = null
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
Text(
- text = email.email,
- style = MaterialTheme.typography.titleMedium,
+ text = "Status: ",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ val statusText = when (email.state) {
+ EmailState.ENABLED -> "Enabled"
+ EmailState.DISABLED -> "Disabled"
+ EmailState.DELETED -> "Deleted"
+ EmailState.PENDING -> "Pending"
+ }
+ Text(
+ text = statusText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = colorPair.content,
fontWeight = FontWeight.Bold
)
}
- IconButton(
+
+ email.createdBy?.let { createdBy ->
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Created by: $createdBy",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ email.formattedCreatedAt?.let { createdAt ->
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "Created: $createdAt",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ email.formattedLastMessageAt?.let { lastMessage ->
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "Last message: $lastMessage",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ if (email.state == EmailState.DISABLED || email.state == EmailState.DELETED) {
+ Button(
onClick = {
- val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
- val clip = ClipData.newPlainText("Email", email.email)
- clipboard.setPrimaryClip(clip)
- Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
- }
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onToggleState()
+ },
+ modifier = Modifier.weight(1f),
+ enabled = !uiState.isUpdating,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = statusColors.enabled.container,
+ contentColor = statusColors.enabled.content
+ )
) {
- Icon(
- imageVector = Icons.Default.ContentCopy,
- contentDescription = "Copy email"
- )
+ if (uiState.isUpdating) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp
+ )
+ } else {
+ Text("Enable")
+ }
}
}
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Row(
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = "Status: ",
- style = MaterialTheme.typography.bodyMedium
- )
- val (statusText, statusColor) = when (email.state) {
- EmailState.ENABLED -> "Enabled" to EnabledGreen
- EmailState.DISABLED -> "Disabled" to DisabledGray
- EmailState.DELETED -> "Deleted" to DeletedRed
- EmailState.PENDING -> "Pending" to PendingOrange
- }
- Text(
- text = statusText,
- style = MaterialTheme.typography.bodyMedium,
- color = statusColor,
- fontWeight = FontWeight.Bold
- )
- }
-
- email.createdBy?.let { createdBy ->
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = "Created by: $createdBy",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
-
- email.formattedCreatedAt?.let { createdAt ->
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = "Created: $createdAt",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
-
- email.formattedLastMessageAt?.let { lastMessage ->
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = "Last message: $lastMessage",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- }
-
- Spacer(modifier = Modifier.height(24.dp))
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- if (email.state == EmailState.DISABLED || email.state == EmailState.DELETED) {
- Button(
- onClick = onToggleState,
- modifier = Modifier.weight(1f),
- enabled = !uiState.isUpdating,
- colors = ButtonDefaults.buttonColors(
- containerColor = EnabledGreen
- )
- ) {
- if (uiState.isUpdating) {
- CircularProgressIndicator(
- modifier = Modifier.size(20.dp),
- strokeWidth = 2.dp
- )
- } else {
- Text("Enable")
+ if (email.state == EmailState.ENABLED || email.state == EmailState.PENDING) {
+ OutlinedButton(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onToggleState()
+ },
+ modifier = Modifier.weight(1f),
+ enabled = !uiState.isUpdating
+ ) {
+ if (uiState.isUpdating) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp
+ )
+ } else {
+ Text("Disable")
+ }
}
}
}
- if (email.state == EmailState.ENABLED || email.state == EmailState.PENDING) {
- OutlinedButton(
- onClick = onToggleState,
- modifier = Modifier.weight(1f),
- enabled = !uiState.isUpdating
- ) {
- if (uiState.isUpdating) {
- CircularProgressIndicator(
- modifier = Modifier.size(20.dp),
- strokeWidth = 2.dp
- )
- } else {
- Text("Disable")
- }
- }
- }
- }
- Spacer(modifier = Modifier.height(24.dp))
+ Spacer(modifier = Modifier.height(24.dp))
- Text(
- text = "Edit Details",
- style = MaterialTheme.typography.titleMedium
- )
-
- Spacer(modifier = Modifier.height(16.dp))
-
- OutlinedTextField(
- value = uiState.editedDescription,
- onValueChange = onDescriptionChange,
- label = { Text("Description") },
- singleLine = true,
- modifier = Modifier.fillMaxWidth(),
- enabled = !uiState.isUpdating
- )
-
- Spacer(modifier = Modifier.height(16.dp))
-
- OutlinedTextField(
- value = uiState.editedForDomain,
- onValueChange = onForDomainChange,
- label = { Text("Associated Domain") },
- singleLine = true,
- modifier = Modifier.fillMaxWidth(),
- enabled = !uiState.isUpdating
- )
-
- Spacer(modifier = Modifier.height(16.dp))
-
- OutlinedTextField(
- value = uiState.editedUrl,
- onValueChange = onUrlChange,
- label = { Text("URL") },
- singleLine = true,
- modifier = Modifier.fillMaxWidth(),
- enabled = !uiState.isUpdating
- )
-
- if (uiState.error != null) {
- Spacer(modifier = Modifier.height(16.dp))
Text(
- text = uiState.error!!,
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.bodyMedium
+ text = "Edit Details",
+ style = MaterialTheme.typography.titleMedium
)
- }
- Spacer(modifier = Modifier.height(24.dp))
+ Spacer(modifier = Modifier.height(16.dp))
- Button(
- onClick = onSaveChanges,
- modifier = Modifier.fillMaxWidth(),
- enabled = !uiState.isUpdating
- ) {
- if (uiState.isUpdating) {
- CircularProgressIndicator(
- modifier = Modifier.size(20.dp),
- color = MaterialTheme.colorScheme.onPrimary,
- strokeWidth = 2.dp
+ OutlinedTextField(
+ value = uiState.editedDescription,
+ onValueChange = onDescriptionChange,
+ label = { Text("Description") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !uiState.isUpdating
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = uiState.editedForDomain,
+ onValueChange = onForDomainChange,
+ label = { Text("Associated Domain") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !uiState.isUpdating
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = uiState.editedUrl,
+ onValueChange = onUrlChange,
+ label = { Text("URL") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !uiState.isUpdating
+ )
+
+ if (uiState.error != null) {
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = uiState.error!!,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyMedium
)
- } else {
- Text("Save Changes")
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Button(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onSaveChanges()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !uiState.isUpdating
+ ) {
+ if (uiState.isUpdating) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = MaterialTheme.colorScheme.onPrimary,
+ strokeWidth = 2.dp
+ )
+ } else {
+ Text("Save Changes")
+ }
}
}
}
}
+
+@Composable
+private fun StatusIcon(
+ state: EmailState,
+ modifier: Modifier = Modifier
+) {
+ val statusColors = FastMaskStatusColors.current
+
+ val (icon, colorPair) = when (state) {
+ EmailState.ENABLED -> Icons.Default.Check to statusColors.enabled
+ EmailState.DISABLED -> Icons.Default.Close to statusColors.disabled
+ EmailState.DELETED -> Icons.Default.Delete to statusColors.deleted
+ EmailState.PENDING -> Icons.Default.HourglassEmpty to statusColors.pending
+ }
+
+ Box(
+ modifier = modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(colorPair.container),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = state.name,
+ modifier = Modifier.size(28.dp),
+ tint = colorPair.content
+ )
+ }
+}
diff --git a/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt b/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt
index e7c7c90..4a168d1 100644
--- a/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt
+++ b/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt
@@ -1,5 +1,8 @@
package com.fastmask.ui.list
+import androidx.compose.animation.AnimatedContentScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -10,24 +13,27 @@ 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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
-import androidx.compose.material3.FloatingActionButton
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.SearchBar
+import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -35,13 +41,19 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
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.LocalView
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.hilt.navigation.compose.hiltViewModel
import com.fastmask.domain.model.MaskedEmail
import com.fastmask.ui.components.ErrorMessage
@@ -49,16 +61,27 @@ import com.fastmask.ui.components.LoadingIndicator
import com.fastmask.ui.components.MaskedEmailCard
import kotlinx.coroutines.flow.collectLatest
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable
fun MaskedEmailListScreen(
onNavigateToCreate: () -> Unit,
onNavigateToDetail: (String) -> Unit,
onLogout: () -> Unit,
+ sharedTransitionScope: SharedTransitionScope,
+ animatedContentScope: AnimatedContentScope,
viewModel: MaskedEmailListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
var showFilterMenu by remember { mutableStateOf(false) }
+ var searchActive by remember { mutableStateOf(false) }
+ val listState = rememberLazyListState()
+ val haptic = LocalHapticFeedback.current
+
+ val expandedFab by remember {
+ derivedStateOf {
+ listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
+ }
+ }
LaunchedEffect(Unit) {
viewModel.events.collectLatest { event ->
@@ -73,15 +96,23 @@ fun MaskedEmailListScreen(
TopAppBar(
title = { Text("Masked Emails") },
colors = TopAppBarDefaults.topAppBarColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer,
- titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
+ containerColor = MaterialTheme.colorScheme.surface,
+ titleContentColor = MaterialTheme.colorScheme.onSurface
),
actions = {
Box {
- IconButton(onClick = { showFilterMenu = true }) {
+ IconButton(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ showFilterMenu = true
+ },
+ modifier = Modifier.semantics {
+ contentDescription = "Filter emails"
+ }
+ ) {
Icon(
imageVector = Icons.Default.FilterList,
- contentDescription = "Filter"
+ contentDescription = null
)
}
DropdownMenu(
@@ -99,22 +130,43 @@ fun MaskedEmailListScreen(
}
}
}
- IconButton(onClick = viewModel::logout) {
+ IconButton(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ viewModel.logout()
+ },
+ modifier = Modifier.semantics {
+ contentDescription = "Logout"
+ }
+ ) {
Icon(
imageVector = Icons.Default.Logout,
- contentDescription = "Logout"
+ contentDescription = null
)
}
}
)
},
floatingActionButton = {
- FloatingActionButton(onClick = onNavigateToCreate) {
- Icon(
- imageVector = Icons.Default.Add,
+ ExtendedFloatingActionButton(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onNavigateToCreate()
+ },
+ expanded = expandedFab,
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = null
+ )
+ },
+ text = { Text("Create") },
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer,
+ contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
+ modifier = Modifier.semantics {
contentDescription = "Create new masked email"
- )
- }
+ }
+ )
}
) { paddingValues ->
Column(
@@ -122,17 +174,23 @@ fun MaskedEmailListScreen(
.fillMaxSize()
.padding(paddingValues)
) {
- SearchBar(
+ M3SearchBar(
query = uiState.searchQuery,
onQueryChange = viewModel::onSearchQueryChange,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ active = searchActive,
+ onActiveChange = { searchActive = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = if (searchActive) 0.dp else 16.dp)
)
- FilterChips(
- selectedFilter = uiState.selectedFilter,
- onFilterSelected = viewModel::onFilterChange,
- modifier = Modifier.padding(horizontal = 16.dp)
- )
+ if (!searchActive) {
+ FilterChips(
+ selectedFilter = uiState.selectedFilter,
+ onFilterSelected = viewModel::onFilterChange,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+ }
Spacer(modifier = Modifier.height(8.dp))
@@ -153,7 +211,10 @@ fun MaskedEmailListScreen(
emails = uiState.filteredEmails,
isRefreshing = uiState.isLoading,
onRefresh = viewModel::loadMaskedEmails,
- onEmailClick = { email -> onNavigateToDetail(email.id) }
+ onEmailClick = { email -> onNavigateToDetail(email.id) },
+ listState = listState,
+ sharedTransitionScope = sharedTransitionScope,
+ animatedContentScope = animatedContentScope
)
}
}
@@ -161,25 +222,58 @@ fun MaskedEmailListScreen(
}
}
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun SearchBar(
+private fun M3SearchBar(
query: String,
onQueryChange: (String) -> Unit,
+ active: Boolean,
+ onActiveChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
- OutlinedTextField(
- value = query,
- onValueChange = onQueryChange,
- modifier = modifier.fillMaxWidth(),
- placeholder = { Text("Search emails...") },
- leadingIcon = {
- Icon(
- imageVector = Icons.Default.Search,
- contentDescription = null
+ val haptic = LocalHapticFeedback.current
+
+ SearchBar(
+ inputField = {
+ SearchBarDefaults.InputField(
+ query = query,
+ onQueryChange = onQueryChange,
+ onSearch = { onActiveChange(false) },
+ expanded = active,
+ onExpandedChange = onActiveChange,
+ placeholder = { Text("Search emails...") },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = null
+ )
+ },
+ trailingIcon = {
+ if (query.isNotEmpty()) {
+ IconButton(
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onQueryChange("")
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Clear search"
+ )
+ }
+ }
+ }
)
},
- singleLine = true
- )
+ expanded = active,
+ onExpandedChange = onActiveChange,
+ modifier = modifier,
+ colors = SearchBarDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ )
+ ) {
+ // Search suggestions could be added here
+ }
}
@Composable
@@ -188,6 +282,8 @@ private fun FilterChips(
onFilterSelected: (EmailFilter) -> Unit,
modifier: Modifier = Modifier
) {
+ val haptic = LocalHapticFeedback.current
+
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
@@ -195,20 +291,26 @@ private fun FilterChips(
EmailFilter.entries.forEach { filter ->
FilterChip(
selected = filter == selectedFilter,
- onClick = { onFilterSelected(filter) },
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onFilterSelected(filter)
+ },
label = { Text(filter.name.lowercase().replaceFirstChar { it.uppercase() }) }
)
}
}
}
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable
private fun EmailList(
emails: List,
isRefreshing: Boolean,
onRefresh: () -> Unit,
- onEmailClick: (MaskedEmail) -> Unit
+ onEmailClick: (MaskedEmail) -> Unit,
+ listState: LazyListState,
+ sharedTransitionScope: SharedTransitionScope,
+ animatedContentScope: AnimatedContentScope
) {
PullToRefreshBox(
isRefreshing = isRefreshing,
@@ -228,6 +330,7 @@ private fun EmailList(
}
} else {
LazyColumn(
+ state = listState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
@@ -237,7 +340,10 @@ private fun EmailList(
) { email ->
MaskedEmailCard(
maskedEmail = email,
- onClick = { onEmailClick(email) }
+ onClick = { onEmailClick(email) },
+ sharedTransitionScope = sharedTransitionScope,
+ animatedContentScope = animatedContentScope,
+ modifier = Modifier.animateItem()
)
}
}
diff --git a/app/src/main/java/com/fastmask/ui/navigation/FastMaskNavHost.kt b/app/src/main/java/com/fastmask/ui/navigation/FastMaskNavHost.kt
index f483a83..7b82a72 100644
--- a/app/src/main/java/com/fastmask/ui/navigation/FastMaskNavHost.kt
+++ b/app/src/main/java/com/fastmask/ui/navigation/FastMaskNavHost.kt
@@ -1,5 +1,11 @@
package com.fastmask.ui.navigation
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionLayout
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
@@ -12,62 +18,99 @@ import com.fastmask.ui.create.CreateMaskedEmailScreen
import com.fastmask.ui.detail.MaskedEmailDetailScreen
import com.fastmask.ui.list.MaskedEmailListScreen
+private const val TRANSITION_DURATION_MS = 300
+
+@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun FastMaskNavHost(
navController: NavHostController,
startDestination: String,
modifier: Modifier = Modifier
) {
- NavHost(
- navController = navController,
- startDestination = startDestination,
- modifier = modifier
- ) {
- composable(NavRoutes.LOGIN) {
- LoginScreen(
- onLoginSuccess = {
- navController.navigate(NavRoutes.EMAIL_LIST) {
- popUpTo(NavRoutes.LOGIN) { inclusive = true }
- }
- }
- )
- }
-
- composable(NavRoutes.EMAIL_LIST) {
- MaskedEmailListScreen(
- onNavigateToCreate = {
- navController.navigate(NavRoutes.CREATE_EMAIL)
- },
- onNavigateToDetail = { emailId ->
- navController.navigate(NavRoutes.emailDetail(emailId))
- },
- onLogout = {
- navController.navigate(NavRoutes.LOGIN) {
- popUpTo(0) { inclusive = true }
- }
- }
- )
- }
-
- composable(NavRoutes.CREATE_EMAIL) {
- CreateMaskedEmailScreen(
- onNavigateBack = {
- navController.popBackStack()
- }
- )
- }
-
- composable(
- route = NavRoutes.EMAIL_DETAIL,
- arguments = listOf(
- navArgument("emailId") { type = NavType.StringType }
- )
+ SharedTransitionLayout {
+ NavHost(
+ navController = navController,
+ startDestination = startDestination,
+ modifier = modifier,
+ enterTransition = {
+ slideIntoContainer(
+ towards = AnimatedContentTransitionScope.SlideDirection.Start,
+ animationSpec = tween(TRANSITION_DURATION_MS)
+ ) + fadeIn(animationSpec = tween(TRANSITION_DURATION_MS))
+ },
+ exitTransition = {
+ slideOutOfContainer(
+ towards = AnimatedContentTransitionScope.SlideDirection.Start,
+ animationSpec = tween(TRANSITION_DURATION_MS)
+ ) + fadeOut(animationSpec = tween(TRANSITION_DURATION_MS))
+ },
+ popEnterTransition = {
+ slideIntoContainer(
+ towards = AnimatedContentTransitionScope.SlideDirection.End,
+ animationSpec = tween(TRANSITION_DURATION_MS)
+ ) + fadeIn(animationSpec = tween(TRANSITION_DURATION_MS))
+ },
+ popExitTransition = {
+ slideOutOfContainer(
+ towards = AnimatedContentTransitionScope.SlideDirection.End,
+ animationSpec = tween(TRANSITION_DURATION_MS)
+ ) + fadeOut(animationSpec = tween(TRANSITION_DURATION_MS))
+ }
) {
- MaskedEmailDetailScreen(
- onNavigateBack = {
- navController.popBackStack()
- }
- )
+ composable(
+ route = NavRoutes.LOGIN,
+ enterTransition = { fadeIn(animationSpec = tween(TRANSITION_DURATION_MS)) },
+ exitTransition = { fadeOut(animationSpec = tween(TRANSITION_DURATION_MS)) }
+ ) {
+ LoginScreen(
+ onLoginSuccess = {
+ navController.navigate(NavRoutes.EMAIL_LIST) {
+ popUpTo(NavRoutes.LOGIN) { inclusive = true }
+ }
+ }
+ )
+ }
+
+ composable(NavRoutes.EMAIL_LIST) {
+ MaskedEmailListScreen(
+ onNavigateToCreate = {
+ navController.navigate(NavRoutes.CREATE_EMAIL)
+ },
+ onNavigateToDetail = { emailId ->
+ navController.navigate(NavRoutes.emailDetail(emailId))
+ },
+ onLogout = {
+ navController.navigate(NavRoutes.LOGIN) {
+ popUpTo(0) { inclusive = true }
+ }
+ },
+ sharedTransitionScope = this@SharedTransitionLayout,
+ animatedContentScope = this@composable
+ )
+ }
+
+ composable(NavRoutes.CREATE_EMAIL) {
+ CreateMaskedEmailScreen(
+ onNavigateBack = {
+ navController.popBackStack()
+ }
+ )
+ }
+
+ composable(
+ route = NavRoutes.EMAIL_DETAIL,
+ arguments = listOf(
+ navArgument("emailId") { type = NavType.StringType }
+ )
+ ) {
+ MaskedEmailDetailScreen(
+ onNavigateBack = {
+ navController.popBackStack()
+ },
+ sharedTransitionScope = this@SharedTransitionLayout,
+ animatedContentScope = this@composable
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/fastmask/ui/theme/Color.kt b/app/src/main/java/com/fastmask/ui/theme/Color.kt
index 7c643e2..718862b 100644
--- a/app/src/main/java/com/fastmask/ui/theme/Color.kt
+++ b/app/src/main/java/com/fastmask/ui/theme/Color.kt
@@ -2,19 +2,103 @@ package com.fastmask.ui.theme
import androidx.compose.ui.graphics.Color
-val Purple80 = Color(0xFFD0BCFF)
-val PurpleGrey80 = Color(0xFFCCC2DC)
-val Pink80 = Color(0xFFEFB8C8)
-
-val Purple40 = Color(0xFF6650a4)
-val PurpleGrey40 = Color(0xFF625b71)
-val Pink40 = Color(0xFF7D5260)
-
+// Fastmail Brand Colors
val FastmailBlue = Color(0xFF0066CC)
val FastmailBlueLight = Color(0xFF4D94DB)
val FastmailBlueDark = Color(0xFF004C99)
-val EnabledGreen = Color(0xFF4CAF50)
-val DisabledGray = Color(0xFF9E9E9E)
-val DeletedRed = Color(0xFFE53935)
-val PendingOrange = Color(0xFFFF9800)
+// M3 Light Theme Colors
+val md_theme_light_primary = Color(0xFF0061A4)
+val md_theme_light_onPrimary = Color(0xFFFFFFFF)
+val md_theme_light_primaryContainer = Color(0xFFD1E4FF)
+val md_theme_light_onPrimaryContainer = Color(0xFF001D36)
+val md_theme_light_secondary = Color(0xFF535F70)
+val md_theme_light_onSecondary = Color(0xFFFFFFFF)
+val md_theme_light_secondaryContainer = Color(0xFFD7E3F7)
+val md_theme_light_onSecondaryContainer = Color(0xFF101C2B)
+val md_theme_light_tertiary = Color(0xFF6B5778)
+val md_theme_light_onTertiary = Color(0xFFFFFFFF)
+val md_theme_light_tertiaryContainer = Color(0xFFF2DAFF)
+val md_theme_light_onTertiaryContainer = Color(0xFF251431)
+val md_theme_light_error = Color(0xFFBA1A1A)
+val md_theme_light_errorContainer = Color(0xFFFFDAD6)
+val md_theme_light_onError = Color(0xFFFFFFFF)
+val md_theme_light_onErrorContainer = Color(0xFF410002)
+val md_theme_light_background = Color(0xFFFDFCFF)
+val md_theme_light_onBackground = Color(0xFF1A1C1E)
+val md_theme_light_surface = Color(0xFFFDFCFF)
+val md_theme_light_onSurface = Color(0xFF1A1C1E)
+val md_theme_light_surfaceVariant = Color(0xFFDFE2EB)
+val md_theme_light_onSurfaceVariant = Color(0xFF43474E)
+val md_theme_light_outline = Color(0xFF73777F)
+val md_theme_light_outlineVariant = Color(0xFFC3C7CF)
+val md_theme_light_inverseOnSurface = Color(0xFFF1F0F4)
+val md_theme_light_inverseSurface = Color(0xFF2F3033)
+val md_theme_light_inversePrimary = Color(0xFF9ECAFF)
+val md_theme_light_surfaceTint = Color(0xFF0061A4)
+val md_theme_light_scrim = Color(0xFF000000)
+
+// M3 Surface Containers (Light)
+val md_theme_light_surfaceContainerLowest = Color(0xFFFFFFFF)
+val md_theme_light_surfaceContainerLow = Color(0xFFF7F6FA)
+val md_theme_light_surfaceContainer = Color(0xFFF1F0F4)
+val md_theme_light_surfaceContainerHigh = Color(0xFFEBEBEF)
+val md_theme_light_surfaceContainerHighest = Color(0xFFE6E5E9)
+
+// M3 Dark Theme Colors
+val md_theme_dark_primary = Color(0xFF9ECAFF)
+val md_theme_dark_onPrimary = Color(0xFF003258)
+val md_theme_dark_primaryContainer = Color(0xFF00497D)
+val md_theme_dark_onPrimaryContainer = Color(0xFFD1E4FF)
+val md_theme_dark_secondary = Color(0xFFBBC7DB)
+val md_theme_dark_onSecondary = Color(0xFF253140)
+val md_theme_dark_secondaryContainer = Color(0xFF3B4858)
+val md_theme_dark_onSecondaryContainer = Color(0xFFD7E3F7)
+val md_theme_dark_tertiary = Color(0xFFD6BEE4)
+val md_theme_dark_onTertiary = Color(0xFF3B2948)
+val md_theme_dark_tertiaryContainer = Color(0xFF523F5F)
+val md_theme_dark_onTertiaryContainer = Color(0xFFF2DAFF)
+val md_theme_dark_error = Color(0xFFFFB4AB)
+val md_theme_dark_errorContainer = Color(0xFF93000A)
+val md_theme_dark_onError = Color(0xFF690005)
+val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
+val md_theme_dark_background = Color(0xFF1A1C1E)
+val md_theme_dark_onBackground = Color(0xFFE2E2E6)
+val md_theme_dark_surface = Color(0xFF1A1C1E)
+val md_theme_dark_onSurface = Color(0xFFE2E2E6)
+val md_theme_dark_surfaceVariant = Color(0xFF43474E)
+val md_theme_dark_onSurfaceVariant = Color(0xFFC3C7CF)
+val md_theme_dark_outline = Color(0xFF8D9199)
+val md_theme_dark_outlineVariant = Color(0xFF43474E)
+val md_theme_dark_inverseOnSurface = Color(0xFF1A1C1E)
+val md_theme_dark_inverseSurface = Color(0xFFE2E2E6)
+val md_theme_dark_inversePrimary = Color(0xFF0061A4)
+val md_theme_dark_surfaceTint = Color(0xFF9ECAFF)
+val md_theme_dark_scrim = Color(0xFF000000)
+
+// M3 Surface Containers (Dark)
+val md_theme_dark_surfaceContainerLowest = Color(0xFF0F1113)
+val md_theme_dark_surfaceContainerLow = Color(0xFF1A1C1E)
+val md_theme_dark_surfaceContainer = Color(0xFF1E2022)
+val md_theme_dark_surfaceContainerHigh = Color(0xFF282A2D)
+val md_theme_dark_surfaceContainerHighest = Color(0xFF333537)
+
+// Semantic Status Colors - Light Theme (Tonal Containers)
+val StatusEnabledContainerLight = Color(0xFFD4F5D0)
+val StatusEnabledContentLight = Color(0xFF0D5F0D)
+val StatusDisabledContainerLight = Color(0xFFE0E0E0)
+val StatusDisabledContentLight = Color(0xFF5C5C5C)
+val StatusDeletedContainerLight = Color(0xFFFFDAD6)
+val StatusDeletedContentLight = Color(0xFFBA1A1A)
+val StatusPendingContainerLight = Color(0xFFFFE0B2)
+val StatusPendingContentLight = Color(0xFFB36B00)
+
+// Semantic Status Colors - Dark Theme (Tonal Containers)
+val StatusEnabledContainerDark = Color(0xFF1E4D1E)
+val StatusEnabledContentDark = Color(0xFF90EE90)
+val StatusDisabledContainerDark = Color(0xFF3D3D3D)
+val StatusDisabledContentDark = Color(0xFFB0B0B0)
+val StatusDeletedContainerDark = Color(0xFF93000A)
+val StatusDeletedContentDark = Color(0xFFFFB4AB)
+val StatusPendingContainerDark = Color(0xFF7A4700)
+val StatusPendingContentDark = Color(0xFFFFCC80)
diff --git a/app/src/main/java/com/fastmask/ui/theme/StatusColors.kt b/app/src/main/java/com/fastmask/ui/theme/StatusColors.kt
new file mode 100644
index 0000000..90a909d
--- /dev/null
+++ b/app/src/main/java/com/fastmask/ui/theme/StatusColors.kt
@@ -0,0 +1,66 @@
+package com.fastmask.ui.theme
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+
+@Immutable
+data class StatusColorPair(
+ val container: Color,
+ val content: Color
+)
+
+@Immutable
+data class StatusColors(
+ val enabled: StatusColorPair,
+ val disabled: StatusColorPair,
+ val deleted: StatusColorPair,
+ val pending: StatusColorPair
+)
+
+val LightStatusColors = StatusColors(
+ enabled = StatusColorPair(
+ container = StatusEnabledContainerLight,
+ content = StatusEnabledContentLight
+ ),
+ disabled = StatusColorPair(
+ container = StatusDisabledContainerLight,
+ content = StatusDisabledContentLight
+ ),
+ deleted = StatusColorPair(
+ container = StatusDeletedContainerLight,
+ content = StatusDeletedContentLight
+ ),
+ pending = StatusColorPair(
+ container = StatusPendingContainerLight,
+ content = StatusPendingContentLight
+ )
+)
+
+val DarkStatusColors = StatusColors(
+ enabled = StatusColorPair(
+ container = StatusEnabledContainerDark,
+ content = StatusEnabledContentDark
+ ),
+ disabled = StatusColorPair(
+ container = StatusDisabledContainerDark,
+ content = StatusDisabledContentDark
+ ),
+ deleted = StatusColorPair(
+ container = StatusDeletedContainerDark,
+ content = StatusDeletedContentDark
+ ),
+ pending = StatusColorPair(
+ container = StatusPendingContainerDark,
+ content = StatusPendingContentDark
+ )
+)
+
+val LocalStatusColors = staticCompositionLocalOf { LightStatusColors }
+
+object FastMaskStatusColors {
+ val current: StatusColors
+ @Composable
+ get() = LocalStatusColors.current
+}
diff --git a/app/src/main/java/com/fastmask/ui/theme/Theme.kt b/app/src/main/java/com/fastmask/ui/theme/Theme.kt
index b37eea0..4aff11e 100644
--- a/app/src/main/java/com/fastmask/ui/theme/Theme.kt
+++ b/app/src/main/java/com/fastmask/ui/theme/Theme.kt
@@ -1,6 +1,5 @@
package com.fastmask.ui.theme
-import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
@@ -9,37 +8,81 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.SideEffect
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.toArgb
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalView
-import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
- primary = FastmailBlueLight,
- secondary = PurpleGrey80,
- tertiary = Pink80,
- background = Color(0xFF121212),
- surface = Color(0xFF1E1E1E),
- onPrimary = Color.White,
- onSecondary = Color.White,
- onTertiary = Color.White,
- onBackground = Color.White,
- onSurface = Color.White
+ primary = md_theme_dark_primary,
+ onPrimary = md_theme_dark_onPrimary,
+ primaryContainer = md_theme_dark_primaryContainer,
+ onPrimaryContainer = md_theme_dark_onPrimaryContainer,
+ secondary = md_theme_dark_secondary,
+ onSecondary = md_theme_dark_onSecondary,
+ secondaryContainer = md_theme_dark_secondaryContainer,
+ onSecondaryContainer = md_theme_dark_onSecondaryContainer,
+ tertiary = md_theme_dark_tertiary,
+ onTertiary = md_theme_dark_onTertiary,
+ tertiaryContainer = md_theme_dark_tertiaryContainer,
+ onTertiaryContainer = md_theme_dark_onTertiaryContainer,
+ error = md_theme_dark_error,
+ errorContainer = md_theme_dark_errorContainer,
+ onError = md_theme_dark_onError,
+ onErrorContainer = md_theme_dark_onErrorContainer,
+ background = md_theme_dark_background,
+ onBackground = md_theme_dark_onBackground,
+ surface = md_theme_dark_surface,
+ onSurface = md_theme_dark_onSurface,
+ surfaceVariant = md_theme_dark_surfaceVariant,
+ onSurfaceVariant = md_theme_dark_onSurfaceVariant,
+ outline = md_theme_dark_outline,
+ outlineVariant = md_theme_dark_outlineVariant,
+ inverseOnSurface = md_theme_dark_inverseOnSurface,
+ inverseSurface = md_theme_dark_inverseSurface,
+ inversePrimary = md_theme_dark_inversePrimary,
+ surfaceTint = md_theme_dark_surfaceTint,
+ scrim = md_theme_dark_scrim,
+ surfaceContainerLowest = md_theme_dark_surfaceContainerLowest,
+ surfaceContainerLow = md_theme_dark_surfaceContainerLow,
+ surfaceContainer = md_theme_dark_surfaceContainer,
+ surfaceContainerHigh = md_theme_dark_surfaceContainerHigh,
+ surfaceContainerHighest = md_theme_dark_surfaceContainerHighest
)
private val LightColorScheme = lightColorScheme(
- primary = FastmailBlue,
- secondary = PurpleGrey40,
- tertiary = Pink40,
- background = Color(0xFFFFFBFE),
- surface = Color(0xFFFFFBFE),
- onPrimary = Color.White,
- onSecondary = Color.White,
- onTertiary = Color.White,
- onBackground = Color(0xFF1C1B1F),
- onSurface = Color(0xFF1C1B1F)
+ primary = md_theme_light_primary,
+ onPrimary = md_theme_light_onPrimary,
+ primaryContainer = md_theme_light_primaryContainer,
+ onPrimaryContainer = md_theme_light_onPrimaryContainer,
+ secondary = md_theme_light_secondary,
+ onSecondary = md_theme_light_onSecondary,
+ secondaryContainer = md_theme_light_secondaryContainer,
+ onSecondaryContainer = md_theme_light_onSecondaryContainer,
+ tertiary = md_theme_light_tertiary,
+ onTertiary = md_theme_light_onTertiary,
+ tertiaryContainer = md_theme_light_tertiaryContainer,
+ onTertiaryContainer = md_theme_light_onTertiaryContainer,
+ error = md_theme_light_error,
+ errorContainer = md_theme_light_errorContainer,
+ onError = md_theme_light_onError,
+ onErrorContainer = md_theme_light_onErrorContainer,
+ background = md_theme_light_background,
+ onBackground = md_theme_light_onBackground,
+ surface = md_theme_light_surface,
+ onSurface = md_theme_light_onSurface,
+ surfaceVariant = md_theme_light_surfaceVariant,
+ onSurfaceVariant = md_theme_light_onSurfaceVariant,
+ outline = md_theme_light_outline,
+ outlineVariant = md_theme_light_outlineVariant,
+ inverseOnSurface = md_theme_light_inverseOnSurface,
+ inverseSurface = md_theme_light_inverseSurface,
+ inversePrimary = md_theme_light_inversePrimary,
+ surfaceTint = md_theme_light_surfaceTint,
+ scrim = md_theme_light_scrim,
+ surfaceContainerLowest = md_theme_light_surfaceContainerLowest,
+ surfaceContainerLow = md_theme_light_surfaceContainerLow,
+ surfaceContainer = md_theme_light_surfaceContainer,
+ surfaceContainerHigh = md_theme_light_surfaceContainerHigh,
+ surfaceContainerHighest = md_theme_light_surfaceContainerHighest
)
@Composable
@@ -57,18 +100,15 @@ fun FastMaskTheme(
else -> LightColorScheme
}
- val view = LocalView.current
- if (!view.isInEditMode) {
- SideEffect {
- val window = (view.context as Activity).window
- window.statusBarColor = colorScheme.surface.toArgb()
- WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
- }
- }
+ val statusColors = if (darkTheme) DarkStatusColors else LightStatusColors
- MaterialTheme(
- colorScheme = colorScheme,
- typography = Typography,
- content = content
- )
+ CompositionLocalProvider(
+ LocalStatusColors provides statusColors
+ ) {
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+ }
}
diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml
new file mode 100644
index 0000000..1da3572
--- /dev/null
+++ b/app/src/main/res/values-v31/themes.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 07bed88..452a969 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -8,4 +8,5 @@
#FF000000
#FFFFFFFF
#0066CC
+ #0066CC
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 6911dbe..1598e7a 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -3,5 +3,12 @@
+
+
diff --git a/docs/index.md b/docs/index.md
index 54a4b48..53d522a 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,27 +1,48 @@
---
layout: default
title: FastMask - Fastmail Masked Email Manager for Android
+description: Native Android app for managing Fastmail masked emails. Create, view, edit, and delete masked email addresses with Material 3 design.
---
# FastMask
-A native Android app for managing Fastmail masked emails.
+**Native Android app for managing Fastmail masked emails**
-Create, view, edit, and manage your masked email addresses directly from your Android device.
+[](https://github.com/pawelorzech/FastMask/releases/latest)
+[](https://github.com/pawelorzech/FastMask/blob/main/LICENSE)
+[](https://developer.android.com/about/versions/oreo)
+
+---
## Download
-**[Download Latest APK](https://github.com/pawelorzech/FastMask/releases/latest)**
+Download Latest APK
+
+Or view all releases on [GitHub](https://github.com/pawelorzech/FastMask/releases)
+
+---
+
+## What is FastMask?
+
+FastMask lets you manage your [Fastmail](https://www.fastmail.com) masked email addresses directly from your Android phone.
+
+**Masked emails** are disposable addresses that forward to your real inbox. They help you:
+- Protect your real email from spam
+- Track which services share your email
+- Easily disable addresses if they get compromised
## Features
-- **View Masked Emails** - Browse all your Fastmail masked email addresses
-- **Create New Masks** - Generate new masked email addresses instantly
-- **Enable/Disable** - Toggle masked emails on or off
-- **Edit Details** - Update description, domain, and URL
-- **Copy to Clipboard** - Quick one-tap copy
-- **Search** - Filter your masked emails
-- **Material You** - Modern Material 3 design
+| Feature | Description |
+|---------|-------------|
+| **View All Masks** | Browse your masked emails in a clean, searchable list |
+| **Create New** | Generate new masked addresses with custom descriptions |
+| **Enable/Disable** | Toggle masks on or off without deleting them |
+| **Edit Details** | Update description, domain, and URL associations |
+| **Quick Copy** | One-tap copy to clipboard |
+| **Delete** | Remove masks you no longer need |
+| **Search & Filter** | Find specific masks instantly |
+| **Material You** | Dynamic theming that adapts to your wallpaper |
## Requirements
@@ -30,21 +51,59 @@ Create, view, edit, and manage your masked email addresses directly from your An
## Quick Start
-1. Download the APK from [Releases](https://github.com/pawelorzech/FastMask/releases)
-2. Install the app on your Android device
-3. Create a Fastmail API token at [Fastmail Settings](https://www.fastmail.com/settings/security/tokens)
-4. Enter your token in FastMask to start managing your masked emails
+### 1. Install the App
-## Privacy
+Download the APK from [Releases](https://github.com/pawelorzech/FastMask/releases) and install it.
-- Your API token is stored securely using Android's EncryptedSharedPreferences
-- Direct communication with Fastmail's API - no third-party servers
-- No analytics or tracking
+### 2. Create a Fastmail API Token
-## Source Code
+1. Log in to [Fastmail](https://www.fastmail.com)
+2. Go to **Settings** → **Privacy & Security** → **Integrations** → **API tokens**
+3. Click **New API token**
+4. Name it "FastMask"
+5. Select scope: **Masked Email** (read/write)
+6. Copy the token
-FastMask is open source. View the code on [GitHub](https://github.com/pawelorzech/FastMask).
+### 3. Log In
+
+Open FastMask, paste your token, and tap "Log in".
---
+## Privacy & Security
+
+- **Secure Storage**: Your API token is encrypted using Android's EncryptedSharedPreferences
+- **Direct Connection**: The app talks directly to Fastmail - no middleman servers
+- **No Tracking**: Zero analytics or data collection
+- **Open Source**: [Full source code](https://github.com/pawelorzech/FastMask) available for review
+
+---
+
+## Tech Stack
+
+- **Kotlin** - 100% Kotlin codebase
+- **Jetpack Compose** - Modern declarative UI
+- **Material 3** - Latest Material Design with dynamic theming
+- **JMAP** - Fastmail's native API protocol
+
+---
+
+## Contributing
+
+FastMask is open source and welcomes contributions!
+
+- [View Source Code](https://github.com/pawelorzech/FastMask)
+- [Report a Bug](https://github.com/pawelorzech/FastMask/issues/new?template=bug_report.md)
+- [Request a Feature](https://github.com/pawelorzech/FastMask/issues/new?template=feature_request.md)
+
+---
+
+## License
+
+FastMask is released under the [MIT License](https://github.com/pawelorzech/FastMask/blob/main/LICENSE).
+
+---
+
+
Made with Kotlin and Jetpack Compose
+