Add templates, splash screen, and UI enhancements

Introduces GitHub issue and PR templates, contributing and security documentation. Adds Android 12+ splash screen support, updates theming and status color handling, and improves MaskedEmail card/detail UI with shared transitions and accessibility. Updates dependencies for Compose and Material3, and enhances README with detailed setup and contribution instructions.
This commit is contained in:
Paweł Orzech 2026-01-31 02:04:54 +01:00
parent 367a7bb604
commit 4a081300cb
No known key found for this signature in database
21 changed files with 1561 additions and 491 deletions

33
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

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

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

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

View file

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

36
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

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

124
CONTRIBUTING.md Normal file
View file

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

239
README.md
View file

@ -1,96 +1,217 @@
# FastMask
<p align="center">
<img src="app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp" width="100" alt="FastMask Logo">
</p>
A native Android app for managing Fastmail masked emails. Create, view, edit, and manage your masked email addresses directly from your Android device.
<h1 align="center">FastMask</h1>
<p align="center">
<strong>Native Android app for managing Fastmail masked emails</strong>
</p>
<p align="center">
<a href="https://github.com/pawelorzech/FastMask/releases/latest">
<img src="https://img.shields.io/github/v/release/pawelorzech/FastMask?style=flat-square" alt="Latest Release">
</a>
<a href="https://github.com/pawelorzech/FastMask/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/pawelorzech/FastMask?style=flat-square" alt="License">
</a>
<a href="https://developer.android.com/about/versions/oreo">
<img src="https://img.shields.io/badge/API-26%2B-brightgreen?style=flat-square" alt="API 26+">
</a>
<a href="https://kotlinlang.org">
<img src="https://img.shields.io/badge/Kotlin-100%25-7F52FF?style=flat-square&logo=kotlin&logoColor=white" alt="Kotlin">
</a>
</p>
<p align="center">
<a href="#features">Features</a>
<a href="#screenshots">Screenshots</a>
<a href="#installation">Installation</a>
<a href="#setup">Setup</a>
<a href="#tech-stack">Tech Stack</a>
<a href="#architecture">Architecture</a>
<a href="#contributing">Contributing</a>
</p>
---
## 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*
<p align="center">
<i>Screenshots coming soon</i>
</p>
<!--
<p align="center">
<img src="docs/screenshots/list.png" width="200" alt="Email List">
<img src="docs/screenshots/create.png" width="200" alt="Create Email">
<img src="docs/screenshots/detail.png" width="200" alt="Email Detail">
</p>
-->
## 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
---
<p align="center">
Made with Kotlin and Jetpack Compose
</p>
<p align="center">
<a href="https://github.com/pawelorzech/FastMask/issues">Report Bug</a>
<a href="https://github.com/pawelorzech/FastMask/issues">Request Feature</a>
</p>

78
SECURITY.md Normal file
View file

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

View file

@ -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")

View file

@ -19,7 +19,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.FastMask">
android:theme="@style/Theme.FastMask.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View file

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

View file

@ -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
)
}
}

View file

@ -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
)
}
}

View file

@ -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<MaskedEmail>,
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()
)
}
}

View file

@ -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
)
}
}
}
}

View file

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

View file

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

View file

@ -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
)
}
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.FastMask" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
</style>
<style name="Theme.FastMask.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splash_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
<item name="windowSplashScreenAnimationDuration">500</item>
<item name="postSplashScreenTheme">@style/Theme.FastMask</item>
</style>
</resources>

View file

@ -8,4 +8,5 @@
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="fastmail_blue">#0066CC</color>
<color name="splash_background">#0066CC</color>
</resources>

View file

@ -3,5 +3,12 @@
<style name="Theme.FastMask" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
</style>
<style name="Theme.FastMask.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splash_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
<item name="postSplashScreenTheme">@style/Theme.FastMask</item>
</style>
</resources>

View file

@ -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.
[![Latest Release](https://img.shields.io/github/v/release/pawelorzech/FastMask?style=flat-square)](https://github.com/pawelorzech/FastMask/releases/latest)
[![License](https://img.shields.io/github/license/pawelorzech/FastMask?style=flat-square)](https://github.com/pawelorzech/FastMask/blob/main/LICENSE)
[![API 26+](https://img.shields.io/badge/API-26%2B-brightgreen?style=flat-square)](https://developer.android.com/about/versions/oreo)
---
## Download
**[Download Latest APK](https://github.com/pawelorzech/FastMask/releases/latest)**
<a href="https://github.com/pawelorzech/FastMask/releases/latest" style="display: inline-block; padding: 12px 24px; background-color: #6750A4; color: white; text-decoration: none; border-radius: 8px; font-weight: bold;">Download Latest APK</a>
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).
---
<p style="text-align: center; color: #666; margin-top: 40px;">
Made with Kotlin and Jetpack Compose
</p>