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:
parent
367a7bb604
commit
4a081300cb
21 changed files with 1561 additions and 491 deletions
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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
|
||||||
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
36
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
124
CONTRIBUTING.md
Normal 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
239
README.md
|
|
@ -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
|
||||||
├── di/ # Dependency injection modules
|
│ ├── local/ # Secure token storage
|
||||||
└── ui/ # Presentation layer (screens, viewmodels)
|
│ └── 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
|
- **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
78
SECURITY.md
Normal 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)
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,57 +27,102 @@ 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
|
||||||
) {
|
) {
|
||||||
Card(
|
val haptic = LocalHapticFeedback.current
|
||||||
modifier = modifier
|
val statusColors = FastMaskStatusColors.current
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(onClick = onClick),
|
val stateDescription = when (maskedEmail.state) {
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
EmailState.ENABLED -> "Enabled"
|
||||||
) {
|
EmailState.DISABLED -> "Disabled"
|
||||||
Row(
|
EmailState.DELETED -> "Deleted"
|
||||||
modifier = Modifier
|
EmailState.PENDING -> "Pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
with(sharedTransitionScope) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.sharedBounds(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
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)
|
Row(
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
modifier = Modifier
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
.fillMaxWidth()
|
||||||
Text(
|
.padding(16.dp),
|
||||||
text = maskedEmail.displayName,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
style = MaterialTheme.typography.titleMedium,
|
) {
|
||||||
maxLines = 1,
|
StatusIcon(
|
||||||
overflow = TextOverflow.Ellipsis
|
state = maskedEmail.state,
|
||||||
|
modifier = Modifier.sharedElement(
|
||||||
|
state = rememberSharedContentState(key = "icon-${maskedEmail.id}"),
|
||||||
|
animatedVisibilityScope = animatedContentScope
|
||||||
|
)
|
||||||
)
|
)
|
||||||
Text(
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
text = maskedEmail.email,
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
maskedEmail.forDomain?.let { domain ->
|
|
||||||
Text(
|
Text(
|
||||||
text = domain,
|
text = maskedEmail.displayName,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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,
|
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 ->
|
||||||
|
Text(
|
||||||
|
text = domain,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -79,18 +130,31 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
Icon(
|
Box(
|
||||||
imageVector = icon,
|
modifier = modifier
|
||||||
contentDescription = state.name,
|
.size(40.dp)
|
||||||
modifier = Modifier.size(24.dp),
|
.clip(CircleShape)
|
||||||
tint = color
|
.background(colorPair.container),
|
||||||
)
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = state.name,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = colorPair.content
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,61 +145,102 @@ 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 ->
|
||||||
when {
|
AnimatedContent(
|
||||||
uiState.isLoading && uiState.email == null -> {
|
targetState = Triple(uiState.isLoading && uiState.email == null, uiState.error != null && uiState.email == null, uiState.email != null),
|
||||||
LoadingIndicator(
|
transitionSpec = {
|
||||||
modifier = Modifier.padding(paddingValues)
|
fadeIn() togetherWith fadeOut()
|
||||||
)
|
},
|
||||||
}
|
label = "content_state"
|
||||||
|
) { (isLoading, hasError, hasEmail) ->
|
||||||
|
when {
|
||||||
|
isLoading -> {
|
||||||
|
LoadingIndicator(
|
||||||
|
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,
|
||||||
modifier = Modifier.padding(paddingValues)
|
modifier = Modifier.padding(paddingValues)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
uiState.email != null -> {
|
hasEmail -> {
|
||||||
EmailDetailContent(
|
EmailDetailContent(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
onDescriptionChange = viewModel::onDescriptionChange,
|
onDescriptionChange = viewModel::onDescriptionChange,
|
||||||
onForDomainChange = viewModel::onForDomainChange,
|
onForDomainChange = viewModel::onForDomainChange,
|
||||||
onUrlChange = viewModel::onUrlChange,
|
onUrlChange = viewModel::onUrlChange,
|
||||||
onToggleState = viewModel::toggleState,
|
onToggleState = viewModel::toggleState,
|
||||||
onSaveChanges = viewModel::saveChanges,
|
onSaveChanges = viewModel::saveChanges,
|
||||||
modifier = Modifier.padding(paddingValues)
|
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
|
@Composable
|
||||||
private fun EmailDetailContent(
|
private fun EmailDetailContent(
|
||||||
uiState: MaskedEmailDetailUiState,
|
uiState: MaskedEmailDetailUiState,
|
||||||
|
|
@ -177,217 +249,291 @@ 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
|
||||||
|
|
||||||
Column(
|
val colorPair = when (email.state) {
|
||||||
modifier = modifier
|
EmailState.ENABLED -> statusColors.enabled
|
||||||
.fillMaxSize()
|
EmailState.DISABLED -> statusColors.disabled
|
||||||
.padding(16.dp)
|
EmailState.DELETED -> statusColors.deleted
|
||||||
.verticalScroll(rememberScrollState())
|
EmailState.PENDING -> statusColors.pending
|
||||||
) {
|
}
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
with(sharedTransitionScope) {
|
||||||
colors = CardDefaults.cardColors(
|
Column(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
modifier = modifier
|
||||||
)
|
.fillMaxSize()
|
||||||
|
.padding(16.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
) {
|
) {
|
||||||
Column(
|
Card(
|
||||||
modifier = Modifier.padding(16.dp)
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(key = "card-${email.id}"),
|
||||||
|
animatedVisibilityScope = animatedContentScope
|
||||||
|
),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Row(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.padding(16.dp)
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Row(
|
||||||
Text(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
text = "Email Address",
|
verticalAlignment = Alignment.CenterVertically
|
||||||
style = MaterialTheme.typography.labelMedium,
|
) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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(
|
||||||
text = email.email,
|
text = "Status: ",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
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
|
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 = {
|
onClick = {
|
||||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
val clip = ClipData.newPlainText("Email", email.email)
|
onToggleState()
|
||||||
clipboard.setPrimaryClip(clip)
|
},
|
||||||
Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
|
modifier = Modifier.weight(1f),
|
||||||
}
|
enabled = !uiState.isUpdating,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = statusColors.enabled.container,
|
||||||
|
contentColor = statusColors.enabled.content
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (uiState.isUpdating) {
|
||||||
imageVector = Icons.Default.ContentCopy,
|
CircularProgressIndicator(
|
||||||
contentDescription = "Copy email"
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Enable")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (email.state == EmailState.ENABLED || email.state == EmailState.PENDING) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
Row(
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
verticalAlignment = Alignment.CenterVertically
|
onToggleState()
|
||||||
) {
|
},
|
||||||
Text(
|
modifier = Modifier.weight(1f),
|
||||||
text = "Status: ",
|
enabled = !uiState.isUpdating
|
||||||
style = MaterialTheme.typography.bodyMedium
|
) {
|
||||||
)
|
if (uiState.isUpdating) {
|
||||||
val (statusText, statusColor) = when (email.state) {
|
CircularProgressIndicator(
|
||||||
EmailState.ENABLED -> "Enabled" to EnabledGreen
|
modifier = Modifier.size(20.dp),
|
||||||
EmailState.DISABLED -> "Disabled" to DisabledGray
|
strokeWidth = 2.dp
|
||||||
EmailState.DELETED -> "Deleted" to DeletedRed
|
)
|
||||||
EmailState.PENDING -> "Pending" to PendingOrange
|
} else {
|
||||||
}
|
Text("Disable")
|
||||||
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 = 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(
|
||||||
text = uiState.error!!,
|
text = "Edit Details",
|
||||||
color = MaterialTheme.colorScheme.error,
|
style = MaterialTheme.typography.titleMedium
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Button(
|
OutlinedTextField(
|
||||||
onClick = onSaveChanges,
|
value = uiState.editedDescription,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onValueChange = onDescriptionChange,
|
||||||
enabled = !uiState.isUpdating
|
label = { Text("Description") },
|
||||||
) {
|
singleLine = true,
|
||||||
if (uiState.isUpdating) {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
CircularProgressIndicator(
|
enabled = !uiState.isUpdating
|
||||||
modifier = Modifier.size(20.dp),
|
)
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
strokeWidth = 2.dp
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
Icon(
|
onClick = {
|
||||||
imageVector = Icons.Default.Add,
|
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"
|
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)
|
||||||
)
|
)
|
||||||
|
|
||||||
FilterChips(
|
if (!searchActive) {
|
||||||
selectedFilter = uiState.selectedFilter,
|
FilterChips(
|
||||||
onFilterSelected = viewModel::onFilterChange,
|
selectedFilter = uiState.selectedFilter,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
onFilterSelected = viewModel::onFilterChange,
|
||||||
)
|
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,25 +222,58 @@ 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 = {
|
||||||
placeholder = { Text("Search emails...") },
|
SearchBarDefaults.InputField(
|
||||||
leadingIcon = {
|
query = query,
|
||||||
Icon(
|
onQueryChange = onQueryChange,
|
||||||
imageVector = Icons.Default.Search,
|
onSearch = { onActiveChange(false) },
|
||||||
contentDescription = null
|
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
|
@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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,62 +18,99 @@ 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
|
||||||
) {
|
) {
|
||||||
NavHost(
|
SharedTransitionLayout {
|
||||||
navController = navController,
|
NavHost(
|
||||||
startDestination = startDestination,
|
navController = navController,
|
||||||
modifier = modifier
|
startDestination = startDestination,
|
||||||
) {
|
modifier = modifier,
|
||||||
composable(NavRoutes.LOGIN) {
|
enterTransition = {
|
||||||
LoginScreen(
|
slideIntoContainer(
|
||||||
onLoginSuccess = {
|
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
||||||
navController.navigate(NavRoutes.EMAIL_LIST) {
|
animationSpec = tween(TRANSITION_DURATION_MS)
|
||||||
popUpTo(NavRoutes.LOGIN) { inclusive = true }
|
) + fadeIn(animationSpec = tween(TRANSITION_DURATION_MS))
|
||||||
}
|
},
|
||||||
}
|
exitTransition = {
|
||||||
)
|
slideOutOfContainer(
|
||||||
}
|
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
||||||
|
animationSpec = tween(TRANSITION_DURATION_MS)
|
||||||
composable(NavRoutes.EMAIL_LIST) {
|
) + fadeOut(animationSpec = tween(TRANSITION_DURATION_MS))
|
||||||
MaskedEmailListScreen(
|
},
|
||||||
onNavigateToCreate = {
|
popEnterTransition = {
|
||||||
navController.navigate(NavRoutes.CREATE_EMAIL)
|
slideIntoContainer(
|
||||||
},
|
towards = AnimatedContentTransitionScope.SlideDirection.End,
|
||||||
onNavigateToDetail = { emailId ->
|
animationSpec = tween(TRANSITION_DURATION_MS)
|
||||||
navController.navigate(NavRoutes.emailDetail(emailId))
|
) + fadeIn(animationSpec = tween(TRANSITION_DURATION_MS))
|
||||||
},
|
},
|
||||||
onLogout = {
|
popExitTransition = {
|
||||||
navController.navigate(NavRoutes.LOGIN) {
|
slideOutOfContainer(
|
||||||
popUpTo(0) { inclusive = true }
|
towards = AnimatedContentTransitionScope.SlideDirection.End,
|
||||||
}
|
animationSpec = tween(TRANSITION_DURATION_MS)
|
||||||
}
|
) + fadeOut(animationSpec = tween(TRANSITION_DURATION_MS))
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
composable(NavRoutes.CREATE_EMAIL) {
|
|
||||||
CreateMaskedEmailScreen(
|
|
||||||
onNavigateBack = {
|
|
||||||
navController.popBackStack()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(
|
|
||||||
route = NavRoutes.EMAIL_DETAIL,
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("emailId") { type = NavType.StringType }
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
MaskedEmailDetailScreen(
|
composable(
|
||||||
onNavigateBack = {
|
route = NavRoutes.LOGIN,
|
||||||
navController.popBackStack()
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
66
app/src/main/java/com/fastmask/ui/theme/StatusColors.kt
Normal file
66
app/src/main/java/com/fastmask/ui/theme/StatusColors.kt
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
CompositionLocalProvider(
|
||||||
colorScheme = colorScheme,
|
LocalStatusColors provides statusColors
|
||||||
typography = Typography,
|
) {
|
||||||
content = content
|
MaterialTheme(
|
||||||
)
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
app/src/main/res/values-v31/themes.xml
Normal file
15
app/src/main/res/values-v31/themes.xml
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
[](https://github.com/pawelorzech/FastMask/releases/latest)
|
||||||
|
[](https://github.com/pawelorzech/FastMask/blob/main/LICENSE)
|
||||||
|
[](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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue