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!

237
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 ## Features
- **View Masked Emails** - Browse all your Fastmail masked email addresses in a clean, organized list | Feature | Description |
- **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 | **View All Masks** | Browse your masked emails in a clean, searchable list |
- **Edit Details** - Update description, associated domain, and URL for any masked email | **Create New** | Generate new masked addresses with custom descriptions |
- **Copy to Clipboard** - Quickly copy email addresses with one tap | **Enable/Disable** | Toggle masks on or off without deleting them |
- **Delete** - Remove masked emails you no longer need | **Edit Details** | Update description, domain, and URL associations |
- **Search** - Filter your masked emails to find what you need | **Quick Copy** | One-tap copy to clipboard |
- **Material You** - Modern Material 3 design with dynamic theming support | **Delete** | Remove masks you no longer need |
| **Search & Filter** | Find specific masks instantly |
| **Material You** | Dynamic theming that adapts to your wallpaper |
## Screenshots ## 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 ## Requirements
- Android 8.0 (API 26) or higher - Android 8.0 (API 26) or higher
- Fastmail account with API access - 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 ## Setup
1. Create a Fastmail API token: ### 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
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 ## Tech Stack
- **Kotlin** - 100% Kotlin codebase | Category | Technology |
- **Jetpack Compose** - Modern declarative UI |----------|------------|
- **Material 3** - Latest Material Design components | **Language** | [Kotlin](https://kotlinlang.org/) 100% |
- **Hilt** - Dependency injection | **UI Framework** | [Jetpack Compose](https://developer.android.com/jetpack/compose) |
- **Coroutines & Flow** - Asynchronous programming | **Design System** | [Material 3](https://m3.material.io/) with dynamic theming |
- **Retrofit + OkHttp** - Network communication | **DI** | [Hilt](https://dagger.dev/hilt/) |
- **Kotlinx Serialization** - JSON parsing | **Networking** | [Retrofit](https://square.github.io/retrofit/) + [OkHttp](https://square.github.io/okhttp/) |
- **JMAP Protocol** - Fastmail's native API | **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 ## Architecture
The app follows Clean Architecture principles with MVVM pattern: The app follows **Clean Architecture** principles with **MVVM** pattern:
``` ```
app/ app/
├── data/ # Data layer (API, repositories) ├── data/ # Data layer
├── domain/ # Business logic (use cases, models) │ ├── 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 ├── di/ # Dependency injection modules
└── ui/ # Presentation layer (screens, viewmodels)
└── 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 - **Local Storage Only**: Your API token is stored locally using Android's [EncryptedSharedPreferences](https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences)
- The app communicates directly with Fastmail's API - no third-party servers - **Direct API Communication**: The app communicates directly with Fastmail's servers - no intermediary servers
- No analytics or tracking - **No Tracking**: Zero analytics, telemetry, or data collection
- **Open Source**: Full source code available for audit
## Contributing ## 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 ## 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 ## Acknowledgments
- [Fastmail](https://www.fastmail.com) for their excellent JMAP API - [Fastmail](https://www.fastmail.com) for their excellent email service and JMAP API
- [JMAP](https://jmap.io) specification for masked emails - [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 { dependencies {
// Core Android // Core Android
implementation("androidx.core:core-ktx:1.12.0") 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.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2") implementation("androidx.activity:activity-compose:1.9.0")
// Compose // Compose
implementation(platform("androidx.compose:compose-bom:2024.09.00")) implementation(platform("androidx.compose:compose-bom:2024.09.00"))
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview") 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.material3:material3")
implementation("androidx.compose.material:material-icons-extended") 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 // Navigation
implementation("androidx.navigation:navigation-compose:2.7.6") implementation("androidx.navigation:navigation-compose:2.7.6")

View file

@ -19,7 +19,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.FastMask"> android:theme="@style/Theme.FastMask.Splash">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <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.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.fastmask.domain.repository.AuthRepository import com.fastmask.domain.repository.AuthRepository
import com.fastmask.ui.navigation.FastMaskNavHost import com.fastmask.ui.navigation.FastMaskNavHost
@ -22,10 +23,23 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var authRepository: AuthRepository lateinit var authRepository: AuthRepository
private var isReady = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
splashScreen.setKeepOnScreenCondition { !isReady }
enableEdgeToEdge() enableEdgeToEdge()
val startDestination = if (authRepository.isLoggedIn()) {
NavRoutes.EMAIL_LIST
} else {
NavRoutes.LOGIN
}
isReady = true
setContent { setContent {
FastMaskTheme { FastMaskTheme {
Surface( Surface(
@ -33,11 +47,6 @@ class MainActivity : ComponentActivity() {
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
val startDestination = if (authRepository.isLoggedIn()) {
NavRoutes.EMAIL_LIST
} else {
NavRoutes.LOGIN
}
FastMaskNavHost( FastMaskNavHost(
navController = navController, navController = navController,

View file

@ -1,6 +1,11 @@
package com.fastmask.ui.components 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.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
@ -21,26 +27,56 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.fastmask.domain.model.EmailState import com.fastmask.domain.model.EmailState
import com.fastmask.domain.model.MaskedEmail import com.fastmask.domain.model.MaskedEmail
import com.fastmask.ui.theme.DeletedRed import com.fastmask.ui.theme.FastMaskStatusColors
import com.fastmask.ui.theme.DisabledGray
import com.fastmask.ui.theme.EnabledGreen
import com.fastmask.ui.theme.PendingOrange
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun MaskedEmailCard( fun MaskedEmailCard(
maskedEmail: MaskedEmail, maskedEmail: MaskedEmail,
onClick: () -> Unit, onClick: () -> Unit,
sharedTransitionScope: SharedTransitionScope,
animatedContentScope: AnimatedContentScope,
modifier: Modifier = Modifier modifier: 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( Card(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick), .sharedBounds(
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) 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
)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -48,21 +84,35 @@ fun MaskedEmailCard(
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
StatusIcon(state = maskedEmail.state) StatusIcon(
state = maskedEmail.state,
modifier = Modifier.sharedElement(
state = rememberSharedContentState(key = "icon-${maskedEmail.id}"),
animatedVisibilityScope = animatedContentScope
)
)
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = maskedEmail.displayName, text = maskedEmail.displayName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
modifier = Modifier.sharedElement(
state = rememberSharedContentState(key = "title-${maskedEmail.id}"),
animatedVisibilityScope = animatedContentScope
)
) )
Text( Text(
text = maskedEmail.email, text = maskedEmail.email,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
modifier = Modifier.sharedElement(
state = rememberSharedContentState(key = "email-${maskedEmail.id}"),
animatedVisibilityScope = animatedContentScope
)
) )
maskedEmail.forDomain?.let { domain -> maskedEmail.forDomain?.let { domain ->
Text( Text(
@ -76,21 +126,35 @@ fun MaskedEmailCard(
} }
} }
} }
}
} }
@Composable @Composable
private fun StatusIcon(state: EmailState) { private fun StatusIcon(
val (icon, color) = when (state) { state: EmailState,
EmailState.ENABLED -> Icons.Default.Check to EnabledGreen modifier: Modifier = Modifier
EmailState.DISABLED -> Icons.Default.Close to DisabledGray ) {
EmailState.DELETED -> Icons.Default.Delete to DeletedRed val statusColors = FastMaskStatusColors.current
EmailState.PENDING -> Icons.Default.HourglassEmpty to PendingOrange
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(40.dp)
.clip(CircleShape)
.background(colorPair.container),
contentAlignment = Alignment.Center
) {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = state.name, contentDescription = state.name,
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
tint = color tint = colorPair.content
) )
}
} }

View file

@ -3,8 +3,16 @@ package com.fastmask.ui.detail
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@ -33,6 +45,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@ -43,40 +59,55 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.fastmask.domain.model.EmailState import com.fastmask.domain.model.EmailState
import com.fastmask.ui.components.ErrorMessage import com.fastmask.ui.components.ErrorMessage
import com.fastmask.ui.components.LoadingIndicator import com.fastmask.ui.components.LoadingIndicator
import com.fastmask.ui.theme.DeletedRed import com.fastmask.ui.theme.FastMaskStatusColors
import com.fastmask.ui.theme.DisabledGray
import com.fastmask.ui.theme.EnabledGreen
import com.fastmask.ui.theme.PendingOrange
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun MaskedEmailDetailScreen( fun MaskedEmailDetailScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
sharedTransitionScope: SharedTransitionScope,
animatedContentScope: AnimatedContentScope,
viewModel: MaskedEmailDetailViewModel = hiltViewModel() viewModel: MaskedEmailDetailViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current val context = LocalContext.current
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.events.collectLatest { event -> viewModel.events.collectLatest { event ->
when (event) { when (event) {
is MaskedEmailDetailEvent.Updated -> { is MaskedEmailDetailEvent.Updated -> {
Toast.makeText(context, "Updated successfully", Toast.LENGTH_SHORT).show() snackbarHostState.showSnackbar(
message = "Updated successfully",
duration = SnackbarDuration.Short
)
} }
is MaskedEmailDetailEvent.Deleted -> { is MaskedEmailDetailEvent.Deleted -> {
Toast.makeText(context, "Deleted successfully", Toast.LENGTH_SHORT).show() snackbarHostState.showSnackbar(
message = "Deleted successfully",
duration = SnackbarDuration.Short
)
onNavigateBack() onNavigateBack()
} }
} }
@ -114,39 +145,65 @@ fun MaskedEmailDetailScreen(
TopAppBar( TopAppBar(
title = { Text("Email Details") }, title = { Text("Email Details") },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onNavigateBack()
},
modifier = Modifier.semantics {
contentDescription = "Navigate back"
}
) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back" contentDescription = null
) )
} }
}, },
actions = { actions = {
if (uiState.email != null) { if (uiState.email != null) {
IconButton(onClick = { showDeleteDialog = true }) { IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showDeleteDialog = true
},
modifier = Modifier.semantics {
contentDescription = "Delete email"
}
) {
Icon( Icon(
imageVector = Icons.Default.Delete, imageVector = Icons.Default.Delete,
contentDescription = "Delete", contentDescription = null,
tint = MaterialTheme.colorScheme.error tint = MaterialTheme.colorScheme.error
) )
} }
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer titleContentColor = MaterialTheme.colorScheme.onSurface
) )
) )
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
} }
) { paddingValues -> ) { 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 { when {
uiState.isLoading && uiState.email == null -> { isLoading -> {
LoadingIndicator( LoadingIndicator(
modifier = Modifier.padding(paddingValues) modifier = Modifier.padding(paddingValues)
) )
} }
uiState.error != null && uiState.email == null -> { hasError -> {
ErrorMessage( ErrorMessage(
message = uiState.error!!, message = uiState.error!!,
onRetry = viewModel::loadEmail, onRetry = viewModel::loadEmail,
@ -154,7 +211,7 @@ fun MaskedEmailDetailScreen(
) )
} }
uiState.email != null -> { hasEmail -> {
EmailDetailContent( EmailDetailContent(
uiState = uiState, uiState = uiState,
onDescriptionChange = viewModel::onDescriptionChange, onDescriptionChange = viewModel::onDescriptionChange,
@ -162,13 +219,28 @@ fun MaskedEmailDetailScreen(
onUrlChange = viewModel::onUrlChange, onUrlChange = viewModel::onUrlChange,
onToggleState = viewModel::toggleState, onToggleState = viewModel::toggleState,
onSaveChanges = viewModel::saveChanges, 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) modifier = Modifier.padding(paddingValues)
) )
} }
} }
} }
}
} }
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
private fun EmailDetailContent( private fun EmailDetailContent(
uiState: MaskedEmailDetailUiState, uiState: MaskedEmailDetailUiState,
@ -177,11 +249,23 @@ private fun EmailDetailContent(
onUrlChange: (String) -> Unit, onUrlChange: (String) -> Unit,
onToggleState: () -> Unit, onToggleState: () -> Unit,
onSaveChanges: () -> Unit, onSaveChanges: () -> Unit,
onCopyEmail: (String) -> Unit,
sharedTransitionScope: SharedTransitionScope,
animatedContentScope: AnimatedContentScope,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val email = uiState.email!! val email = uiState.email!!
val context = LocalContext.current val statusColors = FastMaskStatusColors.current
val haptic = LocalHapticFeedback.current
val colorPair = when (email.state) {
EmailState.ENABLED -> statusColors.enabled
EmailState.DISABLED -> statusColors.disabled
EmailState.DELETED -> statusColors.deleted
EmailState.PENDING -> statusColors.pending
}
with(sharedTransitionScope) {
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@ -189,9 +273,14 @@ private fun EmailDetailContent(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "card-${email.id}"),
animatedVisibilityScope = animatedContentScope
),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant containerColor = MaterialTheme.colorScheme.surfaceContainerLow
) )
) { ) {
Column( Column(
@ -201,29 +290,45 @@ private fun EmailDetailContent(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically 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)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = "Email Address", text = email.displayName,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant modifier = Modifier.sharedElement(
state = rememberSharedContentState(key = "title-${email.id}"),
animatedVisibilityScope = animatedContentScope
)
) )
Text( Text(
text = email.email, text = email.email,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.sharedElement(
state = rememberSharedContentState(key = "email-${email.id}"),
animatedVisibilityScope = animatedContentScope
)
) )
} }
IconButton( IconButton(
onClick = { onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager haptic.performHapticFeedback(HapticFeedbackType.LongPress)
val clip = ClipData.newPlainText("Email", email.email) onCopyEmail(email.email)
clipboard.setPrimaryClip(clip) },
Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() modifier = Modifier.semantics {
contentDescription = "Copy email address"
} }
) { ) {
Icon( Icon(
imageVector = Icons.Default.ContentCopy, imageVector = Icons.Default.ContentCopy,
contentDescription = "Copy email" contentDescription = null
) )
} }
} }
@ -237,16 +342,16 @@ private fun EmailDetailContent(
text = "Status: ", text = "Status: ",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
val (statusText, statusColor) = when (email.state) { val statusText = when (email.state) {
EmailState.ENABLED -> "Enabled" to EnabledGreen EmailState.ENABLED -> "Enabled"
EmailState.DISABLED -> "Disabled" to DisabledGray EmailState.DISABLED -> "Disabled"
EmailState.DELETED -> "Deleted" to DeletedRed EmailState.DELETED -> "Deleted"
EmailState.PENDING -> "Pending" to PendingOrange EmailState.PENDING -> "Pending"
} }
Text( Text(
text = statusText, text = statusText,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = statusColor, color = colorPair.content,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
} }
@ -288,11 +393,15 @@ private fun EmailDetailContent(
) { ) {
if (email.state == EmailState.DISABLED || email.state == EmailState.DELETED) { if (email.state == EmailState.DISABLED || email.state == EmailState.DELETED) {
Button( Button(
onClick = onToggleState, onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onToggleState()
},
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
enabled = !uiState.isUpdating, enabled = !uiState.isUpdating,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = EnabledGreen containerColor = statusColors.enabled.container,
contentColor = statusColors.enabled.content
) )
) { ) {
if (uiState.isUpdating) { if (uiState.isUpdating) {
@ -307,7 +416,10 @@ private fun EmailDetailContent(
} }
if (email.state == EmailState.ENABLED || email.state == EmailState.PENDING) { if (email.state == EmailState.ENABLED || email.state == EmailState.PENDING) {
OutlinedButton( OutlinedButton(
onClick = onToggleState, onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onToggleState()
},
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
enabled = !uiState.isUpdating enabled = !uiState.isUpdating
) { ) {
@ -375,7 +487,10 @@ private fun EmailDetailContent(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Button( Button(
onClick = onSaveChanges, onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onSaveChanges()
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = !uiState.isUpdating enabled = !uiState.isUpdating
) { ) {
@ -390,4 +505,35 @@ private fun EmailDetailContent(
} }
} }
} }
}
}
@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 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.FilterList
import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
@ -35,13 +41,19 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.unit.dp
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.fastmask.domain.model.MaskedEmail import com.fastmask.domain.model.MaskedEmail
import com.fastmask.ui.components.ErrorMessage import com.fastmask.ui.components.ErrorMessage
@ -49,16 +61,27 @@ import com.fastmask.ui.components.LoadingIndicator
import com.fastmask.ui.components.MaskedEmailCard import com.fastmask.ui.components.MaskedEmailCard
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun MaskedEmailListScreen( fun MaskedEmailListScreen(
onNavigateToCreate: () -> Unit, onNavigateToCreate: () -> Unit,
onNavigateToDetail: (String) -> Unit, onNavigateToDetail: (String) -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
sharedTransitionScope: SharedTransitionScope,
animatedContentScope: AnimatedContentScope,
viewModel: MaskedEmailListViewModel = hiltViewModel() viewModel: MaskedEmailListViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
var showFilterMenu by remember { mutableStateOf(false) } 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) { LaunchedEffect(Unit) {
viewModel.events.collectLatest { event -> viewModel.events.collectLatest { event ->
@ -73,15 +96,23 @@ fun MaskedEmailListScreen(
TopAppBar( TopAppBar(
title = { Text("Masked Emails") }, title = { Text("Masked Emails") },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer titleContentColor = MaterialTheme.colorScheme.onSurface
), ),
actions = { actions = {
Box { Box {
IconButton(onClick = { showFilterMenu = true }) { IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showFilterMenu = true
},
modifier = Modifier.semantics {
contentDescription = "Filter emails"
}
) {
Icon( Icon(
imageVector = Icons.Default.FilterList, imageVector = Icons.Default.FilterList,
contentDescription = "Filter" contentDescription = null
) )
} }
DropdownMenu( 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( Icon(
imageVector = Icons.Default.Logout, imageVector = Icons.Default.Logout,
contentDescription = "Logout" contentDescription = null
) )
} }
} }
) )
}, },
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = onNavigateToCreate) { ExtendedFloatingActionButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onNavigateToCreate()
},
expanded = expandedFab,
icon = {
Icon( Icon(
imageVector = Icons.Default.Add, imageVector = Icons.Default.Add,
contentDescription = "Create new masked email" contentDescription = null
) )
},
text = { Text("Create") },
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.semantics {
contentDescription = "Create new masked email"
} }
)
} }
) { paddingValues -> ) { paddingValues ->
Column( Column(
@ -122,17 +174,23 @@ fun MaskedEmailListScreen(
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
SearchBar( M3SearchBar(
query = uiState.searchQuery, query = uiState.searchQuery,
onQueryChange = viewModel::onSearchQueryChange, 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)
) )
if (!searchActive) {
FilterChips( FilterChips(
selectedFilter = uiState.selectedFilter, selectedFilter = uiState.selectedFilter,
onFilterSelected = viewModel::onFilterChange, onFilterSelected = viewModel::onFilterChange,
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) )
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -153,7 +211,10 @@ fun MaskedEmailListScreen(
emails = uiState.filteredEmails, emails = uiState.filteredEmails,
isRefreshing = uiState.isLoading, isRefreshing = uiState.isLoading,
onRefresh = viewModel::loadMaskedEmails, onRefresh = viewModel::loadMaskedEmails,
onEmailClick = { email -> onNavigateToDetail(email.id) } onEmailClick = { email -> onNavigateToDetail(email.id) },
listState = listState,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope
) )
} }
} }
@ -161,16 +222,25 @@ fun MaskedEmailListScreen(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun SearchBar( private fun M3SearchBar(
query: String, query: String,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
active: Boolean,
onActiveChange: (Boolean) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
OutlinedTextField( val haptic = LocalHapticFeedback.current
value = query,
onValueChange = onQueryChange, SearchBar(
modifier = modifier.fillMaxWidth(), inputField = {
SearchBarDefaults.InputField(
query = query,
onQueryChange = onQueryChange,
onSearch = { onActiveChange(false) },
expanded = active,
onExpandedChange = onActiveChange,
placeholder = { Text("Search emails...") }, placeholder = { Text("Search emails...") },
leadingIcon = { leadingIcon = {
Icon( Icon(
@ -178,8 +248,32 @@ private fun SearchBar(
contentDescription = null contentDescription = null
) )
}, },
singleLine = true trailingIcon = {
if (query.isNotEmpty()) {
IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onQueryChange("")
}
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Clear search"
) )
}
}
}
)
},
expanded = active,
onExpandedChange = onActiveChange,
modifier = modifier,
colors = SearchBarDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
// Search suggestions could be added here
}
} }
@Composable @Composable
@ -188,6 +282,8 @@ private fun FilterChips(
onFilterSelected: (EmailFilter) -> Unit, onFilterSelected: (EmailFilter) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val haptic = LocalHapticFeedback.current
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
@ -195,20 +291,26 @@ private fun FilterChips(
EmailFilter.entries.forEach { filter -> EmailFilter.entries.forEach { filter ->
FilterChip( FilterChip(
selected = filter == selectedFilter, selected = filter == selectedFilter,
onClick = { onFilterSelected(filter) }, onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onFilterSelected(filter)
},
label = { Text(filter.name.lowercase().replaceFirstChar { it.uppercase() }) } label = { Text(filter.name.lowercase().replaceFirstChar { it.uppercase() }) }
) )
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable @Composable
private fun EmailList( private fun EmailList(
emails: List<MaskedEmail>, emails: List<MaskedEmail>,
isRefreshing: Boolean, isRefreshing: Boolean,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onEmailClick: (MaskedEmail) -> Unit onEmailClick: (MaskedEmail) -> Unit,
listState: LazyListState,
sharedTransitionScope: SharedTransitionScope,
animatedContentScope: AnimatedContentScope
) { ) {
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
@ -228,6 +330,7 @@ private fun EmailList(
} }
} else { } else {
LazyColumn( LazyColumn(
state = listState,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
@ -237,7 +340,10 @@ private fun EmailList(
) { email -> ) { email ->
MaskedEmailCard( MaskedEmailCard(
maskedEmail = email, 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 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.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
@ -12,18 +18,50 @@ import com.fastmask.ui.create.CreateMaskedEmailScreen
import com.fastmask.ui.detail.MaskedEmailDetailScreen import com.fastmask.ui.detail.MaskedEmailDetailScreen
import com.fastmask.ui.list.MaskedEmailListScreen import com.fastmask.ui.list.MaskedEmailListScreen
private const val TRANSITION_DURATION_MS = 300
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun FastMaskNavHost( fun FastMaskNavHost(
navController: NavHostController, navController: NavHostController,
startDestination: String, startDestination: String,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
SharedTransitionLayout {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = startDestination,
modifier = modifier 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))
}
) {
composable(
route = NavRoutes.LOGIN,
enterTransition = { fadeIn(animationSpec = tween(TRANSITION_DURATION_MS)) },
exitTransition = { fadeOut(animationSpec = tween(TRANSITION_DURATION_MS)) }
) { ) {
composable(NavRoutes.LOGIN) {
LoginScreen( LoginScreen(
onLoginSuccess = { onLoginSuccess = {
navController.navigate(NavRoutes.EMAIL_LIST) { navController.navigate(NavRoutes.EMAIL_LIST) {
@ -45,7 +83,9 @@ fun FastMaskNavHost(
navController.navigate(NavRoutes.LOGIN) { navController.navigate(NavRoutes.LOGIN) {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
} }
} },
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@composable
) )
} }
@ -66,8 +106,11 @@ fun FastMaskNavHost(
MaskedEmailDetailScreen( MaskedEmailDetailScreen(
onNavigateBack = { onNavigateBack = {
navController.popBackStack() 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 import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF) // Fastmail Brand Colors
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
val FastmailBlue = Color(0xFF0066CC) val FastmailBlue = Color(0xFF0066CC)
val FastmailBlueLight = Color(0xFF4D94DB) val FastmailBlueLight = Color(0xFF4D94DB)
val FastmailBlueDark = Color(0xFF004C99) val FastmailBlueDark = Color(0xFF004C99)
val EnabledGreen = Color(0xFF4CAF50) // M3 Light Theme Colors
val DisabledGray = Color(0xFF9E9E9E) val md_theme_light_primary = Color(0xFF0061A4)
val DeletedRed = Color(0xFFE53935) val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val PendingOrange = Color(0xFFFF9800) 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 package com.fastmask.ui.theme
import android.app.Activity
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -9,37 +8,81 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme( private val DarkColorScheme = darkColorScheme(
primary = FastmailBlueLight, primary = md_theme_dark_primary,
secondary = PurpleGrey80, onPrimary = md_theme_dark_onPrimary,
tertiary = Pink80, primaryContainer = md_theme_dark_primaryContainer,
background = Color(0xFF121212), onPrimaryContainer = md_theme_dark_onPrimaryContainer,
surface = Color(0xFF1E1E1E), secondary = md_theme_dark_secondary,
onPrimary = Color.White, onSecondary = md_theme_dark_onSecondary,
onSecondary = Color.White, secondaryContainer = md_theme_dark_secondaryContainer,
onTertiary = Color.White, onSecondaryContainer = md_theme_dark_onSecondaryContainer,
onBackground = Color.White, tertiary = md_theme_dark_tertiary,
onSurface = Color.White 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( private val LightColorScheme = lightColorScheme(
primary = FastmailBlue, primary = md_theme_light_primary,
secondary = PurpleGrey40, onPrimary = md_theme_light_onPrimary,
tertiary = Pink40, primaryContainer = md_theme_light_primaryContainer,
background = Color(0xFFFFFBFE), onPrimaryContainer = md_theme_light_onPrimaryContainer,
surface = Color(0xFFFFFBFE), secondary = md_theme_light_secondary,
onPrimary = Color.White, onSecondary = md_theme_light_onSecondary,
onSecondary = Color.White, secondaryContainer = md_theme_light_secondaryContainer,
onTertiary = Color.White, onSecondaryContainer = md_theme_light_onSecondaryContainer,
onBackground = Color(0xFF1C1B1F), tertiary = md_theme_light_tertiary,
onSurface = Color(0xFF1C1B1F) 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 @Composable
@ -57,18 +100,15 @@ fun FastMaskTheme(
else -> LightColorScheme else -> LightColorScheme
} }
val view = LocalView.current val statusColors = if (darkTheme) DarkStatusColors else LightStatusColors
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.surface.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
CompositionLocalProvider(
LocalStatusColors provides statusColors
) {
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = Typography, typography = Typography,
content = content 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="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<color name="fastmail_blue">#0066CC</color> <color name="fastmail_blue">#0066CC</color>
<color name="splash_background">#0066CC</color>
</resources> </resources>

View file

@ -3,5 +3,12 @@
<style name="Theme.FastMask" parent="android:Theme.Material.Light.NoActionBar"> <style name="Theme.FastMask" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@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> </style>
</resources> </resources>

View file

@ -1,27 +1,48 @@
--- ---
layout: default layout: default
title: FastMask - Fastmail Masked Email Manager for Android 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 # 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
**[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 ## Features
- **View Masked Emails** - Browse all your Fastmail masked email addresses | Feature | Description |
- **Create New Masks** - Generate new masked email addresses instantly |---------|-------------|
- **Enable/Disable** - Toggle masked emails on or off | **View All Masks** | Browse your masked emails in a clean, searchable list |
- **Edit Details** - Update description, domain, and URL | **Create New** | Generate new masked addresses with custom descriptions |
- **Copy to Clipboard** - Quick one-tap copy | **Enable/Disable** | Toggle masks on or off without deleting them |
- **Search** - Filter your masked emails | **Edit Details** | Update description, domain, and URL associations |
- **Material You** - Modern Material 3 design | **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 ## Requirements
@ -30,21 +51,59 @@ Create, view, edit, and manage your masked email addresses directly from your An
## Quick Start ## Quick Start
1. Download the APK from [Releases](https://github.com/pawelorzech/FastMask/releases) ### 1. Install the App
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
## 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 ### 2. Create a Fastmail API Token
- Direct communication with Fastmail's API - no third-party servers
- No analytics or tracking
## 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 Made with Kotlin and Jetpack Compose
</p>