diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..037f643 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,33 @@ +--- +name: Bug Report +about: Report a bug to help us improve FastMask +title: '[Bug] ' +labels: bug +assignees: '' +--- + +## Describe the Bug +A clear and concise description of what the bug is. + +## Steps to Reproduce +1. Go to '...' +2. Tap on '...' +3. Scroll to '...' +4. See error + +## Expected Behavior +What you expected to happen. + +## Actual Behavior +What actually happened. + +## Screenshots +If applicable, add screenshots to help explain your problem. + +## Environment +- **Device**: [e.g., Pixel 7, Samsung Galaxy S23] +- **Android Version**: [e.g., Android 14] +- **App Version**: [e.g., 1.0.0] + +## Additional Context +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b42ed5b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Documentation + url: https://github.com/pawelorzech/FastMask#readme + about: Check the README for setup and usage instructions diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..742f780 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,25 @@ +--- +name: Feature Request +about: Suggest a new feature for FastMask +title: '[Feature] ' +labels: enhancement +assignees: '' +--- + +## Problem Statement +A clear description of the problem or need. Ex. "I'm always frustrated when..." + +## Proposed Solution +Describe the solution you'd like. + +## Alternatives Considered +Any alternative solutions or features you've considered. + +## Use Case +Describe how you would use this feature. + +## Mockups / Examples +If applicable, add mockups, sketches, or examples from other apps. + +## Additional Context +Add any other context about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..80e85c4 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,36 @@ +## Description +Brief description of the changes in this PR. + +## Type of Change +- [ ] Bug fix (non-breaking change that fixes an issue) +- [ ] New feature (non-breaking change that adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) +- [ ] Documentation update + +## Related Issues +Fixes #(issue number) + +## Changes Made +- Change 1 +- Change 2 +- Change 3 + +## Testing +Describe the tests you ran to verify your changes: +- [ ] Tested on emulator (API level: ) +- [ ] Tested on physical device (model: ) +- [ ] Unit tests pass +- [ ] UI looks correct in light and dark mode + +## Screenshots (if applicable) +| Before | After | +|--------|-------| +| | | + +## Checklist +- [ ] My code follows the project's style guidelines +- [ ] I have performed a self-review of my code +- [ ] I have commented my code where necessary +- [ ] I have updated the documentation if needed +- [ ] My changes generate no new warnings +- [ ] Any dependent changes have been merged and published diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5fb64a2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,124 @@ +# Contributing to FastMask + +First off, thank you for considering contributing to FastMask! It's people like you that make FastMask a great tool for managing Fastmail masked emails. + +## Code of Conduct + +By participating in this project, you agree to maintain a respectful and inclusive environment for everyone. + +## How Can I Contribute? + +### Reporting Bugs + +Before creating bug reports, please check existing issues to avoid duplicates. When you create a bug report, include as many details as possible: + +- **Use a clear and descriptive title** +- **Describe the exact steps to reproduce the problem** +- **Describe the behavior you observed and what you expected** +- **Include your Android version and device model** +- **Include screenshots if applicable** + +### Suggesting Features + +Feature suggestions are welcome! Please: + +- **Use a clear and descriptive title** +- **Provide a detailed description of the proposed feature** +- **Explain why this feature would be useful** +- **Include mockups or examples if possible** + +### Pull Requests + +1. Fork the repository +2. Create a feature branch from `main`: + ```bash + git checkout -b feature/your-feature-name + ``` +3. Make your changes +4. Test your changes thoroughly +5. Commit with a meaningful message: + ```bash + git commit -m "Add: description of your changes" + ``` +6. Push to your fork: + ```bash + git push origin feature/your-feature-name + ``` +7. Open a Pull Request + +## Development Setup + +### Prerequisites + +- [Android Studio](https://developer.android.com/studio) (latest stable version) +- JDK 17 or higher +- Android SDK with API 34 + +### Building the Project + +```bash +# Clone your fork +git clone https://github.com/YOUR_USERNAME/FastMask.git +cd FastMask + +# Open in Android Studio or build via command line +./gradlew assembleDebug +``` + +### Running Tests + +```bash +# Run unit tests +./gradlew test + +# Run instrumented tests (requires emulator or device) +./gradlew connectedAndroidTest +``` + +## Code Style + +### Kotlin + +- Follow [Kotlin coding conventions](https://kotlinlang.org/docs/coding-conventions.html) +- Use meaningful names for variables, functions, and classes +- Keep functions small and focused +- Use Kotlin idioms (scope functions, extension functions, etc.) + +### Compose + +- Keep composables small and reusable +- Use `remember` and `derivedStateOf` appropriately +- Follow the [Compose API guidelines](https://android.googlesource.com/platform/frameworks/support/+/androidx-main/compose/docs/compose-api-guidelines.md) + +### Architecture + +- Follow Clean Architecture principles +- Keep the data, domain, and UI layers separate +- Use use cases for business logic +- ViewModels should only contain UI state logic + +### Commit Messages + +Use clear, descriptive commit messages: + +- `Add: new feature description` +- `Fix: bug description` +- `Update: what was changed` +- `Refactor: what was refactored` +- `Docs: documentation changes` + +## Project Structure + +``` +app/src/main/java/com/fastmask/ +├── data/ # Data layer (API, storage, repository implementations) +├── domain/ # Domain layer (models, repository interfaces, use cases) +├── di/ # Hilt dependency injection modules +└── ui/ # UI layer (screens, viewmodels, components, theme) +``` + +## Questions? + +Feel free to open an issue with the `question` label if you have any questions about contributing. + +Thank you for contributing! diff --git a/README.md b/README.md index f24258a..e1e3406 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,217 @@ -# FastMask +

+ FastMask Logo +

-A native Android app for managing Fastmail masked emails. Create, view, edit, and manage your masked email addresses directly from your Android device. +

FastMask

+ +

+ Native Android app for managing Fastmail masked emails +

+ +

+ + Latest Release + + + License + + + API 26+ + + + Kotlin + +

+ +

+ Features • + Screenshots • + Installation • + Setup • + Tech Stack • + Architecture • + Contributing +

+ +--- + +## About + +FastMask is a native Android application that lets you manage your [Fastmail](https://www.fastmail.com) masked email addresses directly from your phone. Masked emails are disposable addresses that forward to your real inbox, helping you protect your privacy and reduce spam. ## Features -- **View Masked Emails** - Browse all your Fastmail masked email addresses in a clean, organized list -- **Create New Masks** - Generate new masked email addresses with optional descriptions and domain associations -- **Enable/Disable** - Toggle masked emails on or off without deleting them -- **Edit Details** - Update description, associated domain, and URL for any masked email -- **Copy to Clipboard** - Quickly copy email addresses with one tap -- **Delete** - Remove masked emails you no longer need -- **Search** - Filter your masked emails to find what you need -- **Material You** - Modern Material 3 design with dynamic theming support +| Feature | Description | +|---------|-------------| +| **View All Masks** | Browse your masked emails in a clean, searchable list | +| **Create New** | Generate new masked addresses with custom descriptions | +| **Enable/Disable** | Toggle masks on or off without deleting them | +| **Edit Details** | Update description, domain, and URL associations | +| **Quick Copy** | One-tap copy to clipboard | +| **Delete** | Remove masks you no longer need | +| **Search & Filter** | Find specific masks instantly | +| **Material You** | Dynamic theming that adapts to your wallpaper | ## Screenshots -*Coming soon* +

+ Screenshots coming soon +

+ + + +## Installation + +### Download APK + +1. Go to the [Releases](https://github.com/pawelorzech/FastMask/releases/latest) page +2. Download the latest APK file +3. Enable "Install from unknown sources" if prompted +4. Install the APK + +### Build from Source + +```bash +# Clone the repository +git clone https://github.com/pawelorzech/FastMask.git +cd FastMask + +# Build debug APK +./gradlew assembleDebug + +# Or build release APK +./gradlew assembleRelease +``` + +The APK will be generated in `app/build/outputs/apk/` ## Requirements - Android 8.0 (API 26) or higher - Fastmail account with API access -## Installation - -### From GitHub Releases - -1. Download the latest APK from the [Releases](https://github.com/pawelorzech/FastMask/releases) page -2. Enable "Install from unknown sources" for your browser or file manager -3. Open the APK file to install - -### Build from Source - -1. Clone the repository: - ```bash - git clone https://github.com/pawelorzech/FastMask.git - ``` - -2. Open the project in Android Studio - -3. Build and run on your device or emulator - ## Setup -1. Create a Fastmail API token: - - Log in to [Fastmail](https://www.fastmail.com) - - Go to **Settings** → **Privacy & Security** → **Integrations** → **API tokens** - - Click **New API token** - - Give it a name (e.g., "FastMask") - - Select the scope: **Masked Email** (read/write) - - Copy the generated token +### 1. Create a Fastmail API Token -2. Open FastMask and paste your API token to log in +1. Log in to [Fastmail](https://www.fastmail.com) +2. Navigate to **Settings** → **Privacy & Security** → **Integrations** → **API tokens** +3. Click **New API token** +4. Name it (e.g., "FastMask") +5. Select scope: **Masked Email** (read/write) +6. Copy the generated token + +### 2. Log in to FastMask + +1. Open the app +2. Paste your API token +3. Tap "Log in" + +Your token is stored securely using Android's EncryptedSharedPreferences. ## Tech Stack -- **Kotlin** - 100% Kotlin codebase -- **Jetpack Compose** - Modern declarative UI -- **Material 3** - Latest Material Design components -- **Hilt** - Dependency injection -- **Coroutines & Flow** - Asynchronous programming -- **Retrofit + OkHttp** - Network communication -- **Kotlinx Serialization** - JSON parsing -- **JMAP Protocol** - Fastmail's native API +| Category | Technology | +|----------|------------| +| **Language** | [Kotlin](https://kotlinlang.org/) 100% | +| **UI Framework** | [Jetpack Compose](https://developer.android.com/jetpack/compose) | +| **Design System** | [Material 3](https://m3.material.io/) with dynamic theming | +| **DI** | [Hilt](https://dagger.dev/hilt/) | +| **Networking** | [Retrofit](https://square.github.io/retrofit/) + [OkHttp](https://square.github.io/okhttp/) | +| **Serialization** | [Kotlinx Serialization](https://github.com/Kotlin/kotlinx.serialization) | +| **Async** | [Coroutines](https://kotlinlang.org/docs/coroutines-overview.html) + [Flow](https://kotlinlang.org/docs/flow.html) | +| **API Protocol** | [JMAP](https://jmap.io/) (Fastmail's native protocol) | ## Architecture -The app follows Clean Architecture principles with MVVM pattern: +The app follows **Clean Architecture** principles with **MVVM** pattern: ``` app/ -├── data/ # Data layer (API, repositories) -├── domain/ # Business logic (use cases, models) -├── di/ # Dependency injection modules -└── ui/ # Presentation layer (screens, viewmodels) +├── data/ # Data layer +│ ├── api/ # JMAP API service & models +│ ├── local/ # Secure token storage +│ └── repository/ # Repository implementations +│ +├── domain/ # Business logic layer +│ ├── model/ # Domain models +│ ├── repository/ # Repository interfaces +│ └── usecase/ # Use cases +│ +├── di/ # Dependency injection modules +│ +└── ui/ # Presentation layer + ├── auth/ # Login screen + ├── list/ # Masked email list + ├── create/ # Create new mask + ├── detail/ # View/edit mask details + ├── components/ # Reusable UI components + ├── navigation/ # Navigation setup + └── theme/ # Material 3 theming ``` -## Privacy +## Privacy & Security -- Your API token is stored securely using Android's EncryptedSharedPreferences -- The app communicates directly with Fastmail's API - no third-party servers -- No analytics or tracking +- **Local Storage Only**: Your API token is stored locally using Android's [EncryptedSharedPreferences](https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences) +- **Direct API Communication**: The app communicates directly with Fastmail's servers - no intermediary servers +- **No Tracking**: Zero analytics, telemetry, or data collection +- **Open Source**: Full source code available for audit ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Contributions are welcome! Here's how you can help: + +1. **Fork** the repository +2. **Create** a feature branch (`git checkout -b feature/amazing-feature`) +3. **Commit** your changes (`git commit -m 'Add amazing feature'`) +4. **Push** to the branch (`git push origin feature/amazing-feature`) +5. **Open** a Pull Request + +### Development Setup + +1. Install [Android Studio](https://developer.android.com/studio) (latest stable) +2. Clone the repository +3. Open the project in Android Studio +4. Sync Gradle and run on an emulator or device + +### Code Style + +- Follow [Kotlin coding conventions](https://kotlinlang.org/docs/coding-conventions.html) +- Use meaningful commit messages +- Write tests for new features when applicable + +## Roadmap + +- [ ] Add screenshots to README +- [ ] Biometric authentication option +- [ ] Widget for quick mask creation +- [ ] Export/import functionality +- [ ] Dark/light mode toggle +- [ ] Localization support ## License -This project is open source. See the [LICENSE](LICENSE) file for details. +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. ## Acknowledgments -- [Fastmail](https://www.fastmail.com) for their excellent JMAP API -- [JMAP](https://jmap.io) specification for masked emails +- [Fastmail](https://www.fastmail.com) for their excellent email service and JMAP API +- [JMAP](https://jmap.io/) for the open standard specification +- The Android and Kotlin communities for amazing tools and libraries + +--- + +

+ Made with Kotlin and Jetpack Compose +

+ +

+ Report Bug • + Request Feature +

diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..599a84d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,78 @@ +# Security Policy + +## Reporting a Vulnerability + +The FastMask team takes security seriously. We appreciate your efforts to responsibly disclose your findings. + +### How to Report + +If you discover a security vulnerability, please report it by: + +1. **Opening a private security advisory** on GitHub: + - Go to the [Security tab](https://github.com/pawelorzech/FastMask/security/advisories) + - Click "New draft security advisory" + - Provide details about the vulnerability + +2. **Or emailing directly** (if available in the repository owner's profile) + +### What to Include + +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if any) + +### Response Timeline + +- **Acknowledgment**: Within 48 hours +- **Initial assessment**: Within 1 week +- **Resolution timeline**: Depends on severity, typically 30-90 days + +## Security Measures in FastMask + +### Data Storage + +- API tokens are stored using Android's [EncryptedSharedPreferences](https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences) +- Encryption uses AES-256-GCM for values and AES-256-SIV for keys +- No sensitive data is stored in plain text + +### Network Security + +- All communication with Fastmail uses HTTPS/TLS +- Certificate pinning is recommended for production builds +- No data is sent to third-party servers + +### Privacy + +- No analytics or tracking +- No data collection +- Direct communication with Fastmail API only + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 1.x | :white_check_mark: | + +## Best Practices for Users + +1. **Protect your API token**: Treat it like a password +2. **Use device security**: Enable screen lock on your device +3. **Keep the app updated**: Install updates for security fixes +4. **Review permissions**: The app only requests necessary permissions + +## Scope + +The following are **in scope** for security reports: + +- Authentication and authorization issues +- Data leakage or exposure +- Cryptographic weaknesses +- API security issues + +The following are **out of scope**: + +- Social engineering attacks +- Physical attacks on user devices +- Denial of service attacks +- Issues in third-party dependencies (report to upstream) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 75e34a1..83430d7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -70,16 +70,24 @@ android { dependencies { // Core Android implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") - implementation("androidx.activity:activity-compose:1.8.2") + implementation("androidx.activity:activity-compose:1.9.0") // Compose implementation(platform("androidx.compose:compose-bom:2024.09.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.ui:ui-text-google-fonts:1.6.0") implementation("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.compose.animation:animation") + + // Large screen support + implementation("androidx.compose.material3.adaptive:adaptive:1.0.0") + implementation("androidx.compose.material3.adaptive:adaptive-layout:1.0.0") + implementation("androidx.compose.material3.adaptive:adaptive-navigation:1.0.0") // Navigation implementation("androidx.navigation:navigation-compose:2.7.6") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index baa940b..2785683 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,7 +19,7 @@ + android:theme="@style/Theme.FastMask.Splash"> diff --git a/app/src/main/java/com/fastmask/MainActivity.kt b/app/src/main/java/com/fastmask/MainActivity.kt index 1aea624..d31fe89 100644 --- a/app/src/main/java/com/fastmask/MainActivity.kt +++ b/app/src/main/java/com/fastmask/MainActivity.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.navigation.compose.rememberNavController import com.fastmask.domain.repository.AuthRepository import com.fastmask.ui.navigation.FastMaskNavHost @@ -22,10 +23,23 @@ class MainActivity : ComponentActivity() { @Inject lateinit var authRepository: AuthRepository + private var isReady = false + override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) + + splashScreen.setKeepOnScreenCondition { !isReady } + enableEdgeToEdge() + val startDestination = if (authRepository.isLoggedIn()) { + NavRoutes.EMAIL_LIST + } else { + NavRoutes.LOGIN + } + isReady = true + setContent { FastMaskTheme { Surface( @@ -33,11 +47,6 @@ class MainActivity : ComponentActivity() { color = MaterialTheme.colorScheme.background ) { val navController = rememberNavController() - val startDestination = if (authRepository.isLoggedIn()) { - NavRoutes.EMAIL_LIST - } else { - NavRoutes.LOGIN - } FastMaskNavHost( navController = navController, diff --git a/app/src/main/java/com/fastmask/ui/components/MaskedEmailCard.kt b/app/src/main/java/com/fastmask/ui/components/MaskedEmailCard.kt index 6c3301a..238fb13 100644 --- a/app/src/main/java/com/fastmask/ui/components/MaskedEmailCard.kt +++ b/app/src/main/java/com/fastmask/ui/components/MaskedEmailCard.kt @@ -1,6 +1,11 @@ package com.fastmask.ui.components +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -8,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close @@ -21,57 +27,102 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.fastmask.domain.model.EmailState import com.fastmask.domain.model.MaskedEmail -import com.fastmask.ui.theme.DeletedRed -import com.fastmask.ui.theme.DisabledGray -import com.fastmask.ui.theme.EnabledGreen -import com.fastmask.ui.theme.PendingOrange +import com.fastmask.ui.theme.FastMaskStatusColors +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun MaskedEmailCard( maskedEmail: MaskedEmail, onClick: () -> Unit, + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, modifier: Modifier = Modifier ) { - Card( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) - ) { - Row( - modifier = Modifier + val haptic = LocalHapticFeedback.current + val statusColors = FastMaskStatusColors.current + + val stateDescription = when (maskedEmail.state) { + EmailState.ENABLED -> "Enabled" + EmailState.DISABLED -> "Disabled" + EmailState.DELETED -> "Deleted" + EmailState.PENDING -> "Pending" + } + + with(sharedTransitionScope) { + Card( + modifier = modifier .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically + .sharedBounds( + sharedContentState = rememberSharedContentState(key = "card-${maskedEmail.id}"), + animatedVisibilityScope = animatedContentScope + ) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onClick() + } + .semantics { + contentDescription = "${maskedEmail.displayName}, ${maskedEmail.email}, $stateDescription" + }, + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) ) { - StatusIcon(state = maskedEmail.state) - Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = maskedEmail.displayName, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StatusIcon( + state = maskedEmail.state, + modifier = Modifier.sharedElement( + state = rememberSharedContentState(key = "icon-${maskedEmail.id}"), + animatedVisibilityScope = animatedContentScope + ) ) - Text( - text = maskedEmail.email, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - maskedEmail.forDomain?.let { domain -> + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { Text( - text = domain, - style = MaterialTheme.typography.bodySmall, + text = maskedEmail.displayName, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.sharedElement( + state = rememberSharedContentState(key = "title-${maskedEmail.id}"), + animatedVisibilityScope = animatedContentScope + ) + ) + Text( + text = maskedEmail.email, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.sharedElement( + state = rememberSharedContentState(key = "email-${maskedEmail.id}"), + animatedVisibilityScope = animatedContentScope + ) ) + maskedEmail.forDomain?.let { domain -> + Text( + text = domain, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } } } @@ -79,18 +130,31 @@ fun MaskedEmailCard( } @Composable -private fun StatusIcon(state: EmailState) { - val (icon, color) = when (state) { - EmailState.ENABLED -> Icons.Default.Check to EnabledGreen - EmailState.DISABLED -> Icons.Default.Close to DisabledGray - EmailState.DELETED -> Icons.Default.Delete to DeletedRed - EmailState.PENDING -> Icons.Default.HourglassEmpty to PendingOrange +private fun StatusIcon( + state: EmailState, + modifier: Modifier = Modifier +) { + val statusColors = FastMaskStatusColors.current + + val (icon, colorPair) = when (state) { + EmailState.ENABLED -> Icons.Default.Check to statusColors.enabled + EmailState.DISABLED -> Icons.Default.Close to statusColors.disabled + EmailState.DELETED -> Icons.Default.Delete to statusColors.deleted + EmailState.PENDING -> Icons.Default.HourglassEmpty to statusColors.pending } - Icon( - imageVector = icon, - contentDescription = state.name, - modifier = Modifier.size(24.dp), - tint = color - ) + Box( + modifier = modifier + .size(40.dp) + .clip(CircleShape) + .background(colorPair.container), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = state.name, + modifier = Modifier.size(24.dp), + tint = colorPair.content + ) + } } diff --git a/app/src/main/java/com/fastmask/ui/detail/MaskedEmailDetailScreen.kt b/app/src/main/java/com/fastmask/ui/detail/MaskedEmailDetailScreen.kt index 282f684..405c8df 100644 --- a/app/src/main/java/com/fastmask/ui/detail/MaskedEmailDetailScreen.kt +++ b/app/src/main/java/com/fastmask/ui/detail/MaskedEmailDetailScreen.kt @@ -3,8 +3,16 @@ package com.fastmask.ui.detail import android.content.ClipData import android.content.ClipboardManager import android.content.Context -import android.widget.Toast +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -15,11 +23,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.HourglassEmpty import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -33,6 +45,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -43,40 +59,55 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.fastmask.domain.model.EmailState import com.fastmask.ui.components.ErrorMessage import com.fastmask.ui.components.LoadingIndicator -import com.fastmask.ui.theme.DeletedRed -import com.fastmask.ui.theme.DisabledGray -import com.fastmask.ui.theme.EnabledGreen -import com.fastmask.ui.theme.PendingOrange +import com.fastmask.ui.theme.FastMaskStatusColors import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable fun MaskedEmailDetailScreen( onNavigateBack: () -> Unit, + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, viewModel: MaskedEmailDetailViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current var showDeleteDialog by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val haptic = LocalHapticFeedback.current LaunchedEffect(Unit) { viewModel.events.collectLatest { event -> when (event) { is MaskedEmailDetailEvent.Updated -> { - Toast.makeText(context, "Updated successfully", Toast.LENGTH_SHORT).show() + snackbarHostState.showSnackbar( + message = "Updated successfully", + duration = SnackbarDuration.Short + ) } is MaskedEmailDetailEvent.Deleted -> { - Toast.makeText(context, "Deleted successfully", Toast.LENGTH_SHORT).show() + snackbarHostState.showSnackbar( + message = "Deleted successfully", + duration = SnackbarDuration.Short + ) onNavigateBack() } } @@ -114,61 +145,102 @@ fun MaskedEmailDetailScreen( TopAppBar( title = { Text("Email Details") }, navigationIcon = { - IconButton(onClick = onNavigateBack) { + IconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onNavigateBack() + }, + modifier = Modifier.semantics { + contentDescription = "Navigate back" + } + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" + contentDescription = null ) } }, actions = { if (uiState.email != null) { - IconButton(onClick = { showDeleteDialog = true }) { + IconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + showDeleteDialog = true + }, + modifier = Modifier.semantics { + contentDescription = "Delete email" + } + ) { Icon( imageVector = Icons.Default.Delete, - contentDescription = "Delete", + contentDescription = null, tint = MaterialTheme.colorScheme.error ) } } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface ) ) + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) } ) { paddingValues -> - when { - uiState.isLoading && uiState.email == null -> { - LoadingIndicator( - modifier = Modifier.padding(paddingValues) - ) - } + AnimatedContent( + targetState = Triple(uiState.isLoading && uiState.email == null, uiState.error != null && uiState.email == null, uiState.email != null), + transitionSpec = { + fadeIn() togetherWith fadeOut() + }, + label = "content_state" + ) { (isLoading, hasError, hasEmail) -> + when { + isLoading -> { + LoadingIndicator( + modifier = Modifier.padding(paddingValues) + ) + } - uiState.error != null && uiState.email == null -> { - ErrorMessage( - message = uiState.error!!, - onRetry = viewModel::loadEmail, - modifier = Modifier.padding(paddingValues) - ) - } + hasError -> { + ErrorMessage( + message = uiState.error!!, + onRetry = viewModel::loadEmail, + modifier = Modifier.padding(paddingValues) + ) + } - uiState.email != null -> { - EmailDetailContent( - uiState = uiState, - onDescriptionChange = viewModel::onDescriptionChange, - onForDomainChange = viewModel::onForDomainChange, - onUrlChange = viewModel::onUrlChange, - onToggleState = viewModel::toggleState, - onSaveChanges = viewModel::saveChanges, - modifier = Modifier.padding(paddingValues) - ) + hasEmail -> { + EmailDetailContent( + uiState = uiState, + onDescriptionChange = viewModel::onDescriptionChange, + onForDomainChange = viewModel::onForDomainChange, + onUrlChange = viewModel::onUrlChange, + onToggleState = viewModel::toggleState, + onSaveChanges = viewModel::saveChanges, + onCopyEmail = { email -> + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Email", email) + clipboard.setPrimaryClip(clip) + scope.launch { + snackbarHostState.showSnackbar( + message = "Copied to clipboard", + duration = SnackbarDuration.Short + ) + } + }, + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope, + modifier = Modifier.padding(paddingValues) + ) + } } } } } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun EmailDetailContent( uiState: MaskedEmailDetailUiState, @@ -177,217 +249,291 @@ private fun EmailDetailContent( onUrlChange: (String) -> Unit, onToggleState: () -> Unit, onSaveChanges: () -> Unit, + onCopyEmail: (String) -> Unit, + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, modifier: Modifier = Modifier ) { val email = uiState.email!! - val context = LocalContext.current + val statusColors = FastMaskStatusColors.current + val haptic = LocalHapticFeedback.current - Column( - modifier = modifier - .fillMaxSize() - .padding(16.dp) - .verticalScroll(rememberScrollState()) - ) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) + val colorPair = when (email.state) { + EmailState.ENABLED -> statusColors.enabled + EmailState.DISABLED -> statusColors.disabled + EmailState.DELETED -> statusColors.deleted + EmailState.PENDING -> statusColors.pending + } + + with(sharedTransitionScope) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()) ) { - Column( - modifier = Modifier.padding(16.dp) + Card( + modifier = Modifier + .fillMaxWidth() + .sharedBounds( + sharedContentState = rememberSharedContentState(key = "card-${email.id}"), + animatedVisibilityScope = animatedContentScope + ), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + Column( + modifier = Modifier.padding(16.dp) ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Email Address", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + StatusIcon( + state = email.state, + modifier = Modifier.sharedElement( + state = rememberSharedContentState(key = "icon-${email.id}"), + animatedVisibilityScope = animatedContentScope + ) ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = email.displayName, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.sharedElement( + state = rememberSharedContentState(key = "title-${email.id}"), + animatedVisibilityScope = animatedContentScope + ) + ) + Text( + text = email.email, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.sharedElement( + state = rememberSharedContentState(key = "email-${email.id}"), + animatedVisibilityScope = animatedContentScope + ) + ) + } + IconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onCopyEmail(email.email) + }, + modifier = Modifier.semantics { + contentDescription = "Copy email address" + } + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { Text( - text = email.email, - style = MaterialTheme.typography.titleMedium, + text = "Status: ", + style = MaterialTheme.typography.bodyMedium + ) + val statusText = when (email.state) { + EmailState.ENABLED -> "Enabled" + EmailState.DISABLED -> "Disabled" + EmailState.DELETED -> "Deleted" + EmailState.PENDING -> "Pending" + } + Text( + text = statusText, + style = MaterialTheme.typography.bodyMedium, + color = colorPair.content, fontWeight = FontWeight.Bold ) } - IconButton( + + email.createdBy?.let { createdBy -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Created by: $createdBy", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + email.formattedCreatedAt?.let { createdAt -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Created: $createdAt", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + email.formattedLastMessageAt?.let { lastMessage -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Last message: $lastMessage", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (email.state == EmailState.DISABLED || email.state == EmailState.DELETED) { + Button( onClick = { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Email", email.email) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() - } + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onToggleState() + }, + modifier = Modifier.weight(1f), + enabled = !uiState.isUpdating, + colors = ButtonDefaults.buttonColors( + containerColor = statusColors.enabled.container, + contentColor = statusColors.enabled.content + ) ) { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = "Copy email" - ) + if (uiState.isUpdating) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } else { + Text("Enable") + } } } - - Spacer(modifier = Modifier.height(16.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Status: ", - style = MaterialTheme.typography.bodyMedium - ) - val (statusText, statusColor) = when (email.state) { - EmailState.ENABLED -> "Enabled" to EnabledGreen - EmailState.DISABLED -> "Disabled" to DisabledGray - EmailState.DELETED -> "Deleted" to DeletedRed - EmailState.PENDING -> "Pending" to PendingOrange - } - Text( - text = statusText, - style = MaterialTheme.typography.bodyMedium, - color = statusColor, - fontWeight = FontWeight.Bold - ) - } - - email.createdBy?.let { createdBy -> - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Created by: $createdBy", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - email.formattedCreatedAt?.let { createdAt -> - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Created: $createdAt", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - email.formattedLastMessageAt?.let { lastMessage -> - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Last message: $lastMessage", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (email.state == EmailState.DISABLED || email.state == EmailState.DELETED) { - Button( - onClick = onToggleState, - modifier = Modifier.weight(1f), - enabled = !uiState.isUpdating, - colors = ButtonDefaults.buttonColors( - containerColor = EnabledGreen - ) - ) { - if (uiState.isUpdating) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp - ) - } else { - Text("Enable") + if (email.state == EmailState.ENABLED || email.state == EmailState.PENDING) { + OutlinedButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onToggleState() + }, + modifier = Modifier.weight(1f), + enabled = !uiState.isUpdating + ) { + if (uiState.isUpdating) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } else { + Text("Disable") + } } } } - if (email.state == EmailState.ENABLED || email.state == EmailState.PENDING) { - OutlinedButton( - onClick = onToggleState, - modifier = Modifier.weight(1f), - enabled = !uiState.isUpdating - ) { - if (uiState.isUpdating) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp - ) - } else { - Text("Disable") - } - } - } - } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(24.dp)) - Text( - text = "Edit Details", - style = MaterialTheme.typography.titleMedium - ) - - Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - value = uiState.editedDescription, - onValueChange = onDescriptionChange, - label = { Text("Description") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - enabled = !uiState.isUpdating - ) - - Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - value = uiState.editedForDomain, - onValueChange = onForDomainChange, - label = { Text("Associated Domain") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - enabled = !uiState.isUpdating - ) - - Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - value = uiState.editedUrl, - onValueChange = onUrlChange, - label = { Text("URL") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - enabled = !uiState.isUpdating - ) - - if (uiState.error != null) { - Spacer(modifier = Modifier.height(16.dp)) Text( - text = uiState.error!!, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium + text = "Edit Details", + style = MaterialTheme.typography.titleMedium ) - } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = onSaveChanges, - modifier = Modifier.fillMaxWidth(), - enabled = !uiState.isUpdating - ) { - if (uiState.isUpdating) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp + OutlinedTextField( + value = uiState.editedDescription, + onValueChange = onDescriptionChange, + label = { Text("Description") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isUpdating + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = uiState.editedForDomain, + onValueChange = onForDomainChange, + label = { Text("Associated Domain") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isUpdating + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = uiState.editedUrl, + onValueChange = onUrlChange, + label = { Text("URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isUpdating + ) + + if (uiState.error != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = uiState.error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium ) - } else { - Text("Save Changes") + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onSaveChanges() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isUpdating + ) { + if (uiState.isUpdating) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text("Save Changes") + } } } } } + +@Composable +private fun StatusIcon( + state: EmailState, + modifier: Modifier = Modifier +) { + val statusColors = FastMaskStatusColors.current + + val (icon, colorPair) = when (state) { + EmailState.ENABLED -> Icons.Default.Check to statusColors.enabled + EmailState.DISABLED -> Icons.Default.Close to statusColors.disabled + EmailState.DELETED -> Icons.Default.Delete to statusColors.deleted + EmailState.PENDING -> Icons.Default.HourglassEmpty to statusColors.pending + } + + Box( + modifier = modifier + .size(48.dp) + .clip(CircleShape) + .background(colorPair.container), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = state.name, + modifier = Modifier.size(28.dp), + tint = colorPair.content + ) + } +} diff --git a/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt b/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt index e7c7c90..4a168d1 100644 --- a/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt +++ b/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt @@ -1,5 +1,8 @@ package com.fastmask.ui.list +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,24 +13,27 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -35,13 +41,19 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.hilt.navigation.compose.hiltViewModel import com.fastmask.domain.model.MaskedEmail import com.fastmask.ui.components.ErrorMessage @@ -49,16 +61,27 @@ import com.fastmask.ui.components.LoadingIndicator import com.fastmask.ui.components.MaskedEmailCard import kotlinx.coroutines.flow.collectLatest -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable fun MaskedEmailListScreen( onNavigateToCreate: () -> Unit, onNavigateToDetail: (String) -> Unit, onLogout: () -> Unit, + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, viewModel: MaskedEmailListViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() var showFilterMenu by remember { mutableStateOf(false) } + var searchActive by remember { mutableStateOf(false) } + val listState = rememberLazyListState() + val haptic = LocalHapticFeedback.current + + val expandedFab by remember { + derivedStateOf { + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + } + } LaunchedEffect(Unit) { viewModel.events.collectLatest { event -> @@ -73,15 +96,23 @@ fun MaskedEmailListScreen( TopAppBar( title = { Text("Masked Emails") }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface ), actions = { Box { - IconButton(onClick = { showFilterMenu = true }) { + IconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + showFilterMenu = true + }, + modifier = Modifier.semantics { + contentDescription = "Filter emails" + } + ) { Icon( imageVector = Icons.Default.FilterList, - contentDescription = "Filter" + contentDescription = null ) } DropdownMenu( @@ -99,22 +130,43 @@ fun MaskedEmailListScreen( } } } - IconButton(onClick = viewModel::logout) { + IconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.logout() + }, + modifier = Modifier.semantics { + contentDescription = "Logout" + } + ) { Icon( imageVector = Icons.Default.Logout, - contentDescription = "Logout" + contentDescription = null ) } } ) }, floatingActionButton = { - FloatingActionButton(onClick = onNavigateToCreate) { - Icon( - imageVector = Icons.Default.Add, + ExtendedFloatingActionButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onNavigateToCreate() + }, + expanded = expandedFab, + icon = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null + ) + }, + text = { Text("Create") }, + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.semantics { contentDescription = "Create new masked email" - ) - } + } + ) } ) { paddingValues -> Column( @@ -122,17 +174,23 @@ fun MaskedEmailListScreen( .fillMaxSize() .padding(paddingValues) ) { - SearchBar( + M3SearchBar( query = uiState.searchQuery, onQueryChange = viewModel::onSearchQueryChange, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + active = searchActive, + onActiveChange = { searchActive = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = if (searchActive) 0.dp else 16.dp) ) - FilterChips( - selectedFilter = uiState.selectedFilter, - onFilterSelected = viewModel::onFilterChange, - modifier = Modifier.padding(horizontal = 16.dp) - ) + if (!searchActive) { + FilterChips( + selectedFilter = uiState.selectedFilter, + onFilterSelected = viewModel::onFilterChange, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } Spacer(modifier = Modifier.height(8.dp)) @@ -153,7 +211,10 @@ fun MaskedEmailListScreen( emails = uiState.filteredEmails, isRefreshing = uiState.isLoading, onRefresh = viewModel::loadMaskedEmails, - onEmailClick = { email -> onNavigateToDetail(email.id) } + onEmailClick = { email -> onNavigateToDetail(email.id) }, + listState = listState, + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope ) } } @@ -161,25 +222,58 @@ fun MaskedEmailListScreen( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SearchBar( +private fun M3SearchBar( query: String, onQueryChange: (String) -> Unit, + active: Boolean, + onActiveChange: (Boolean) -> Unit, modifier: Modifier = Modifier ) { - OutlinedTextField( - value = query, - onValueChange = onQueryChange, - modifier = modifier.fillMaxWidth(), - placeholder = { Text("Search emails...") }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null + val haptic = LocalHapticFeedback.current + + SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = onQueryChange, + onSearch = { onActiveChange(false) }, + expanded = active, + onExpandedChange = onActiveChange, + placeholder = { Text("Search emails...") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onQueryChange("") + } + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Clear search" + ) + } + } + } ) }, - singleLine = true - ) + expanded = active, + onExpandedChange = onActiveChange, + modifier = modifier, + colors = SearchBarDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ) + ) { + // Search suggestions could be added here + } } @Composable @@ -188,6 +282,8 @@ private fun FilterChips( onFilterSelected: (EmailFilter) -> Unit, modifier: Modifier = Modifier ) { + val haptic = LocalHapticFeedback.current + Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -195,20 +291,26 @@ private fun FilterChips( EmailFilter.entries.forEach { filter -> FilterChip( selected = filter == selectedFilter, - onClick = { onFilterSelected(filter) }, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onFilterSelected(filter) + }, label = { Text(filter.name.lowercase().replaceFirstChar { it.uppercase() }) } ) } } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable private fun EmailList( emails: List, isRefreshing: Boolean, onRefresh: () -> Unit, - onEmailClick: (MaskedEmail) -> Unit + onEmailClick: (MaskedEmail) -> Unit, + listState: LazyListState, + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope ) { PullToRefreshBox( isRefreshing = isRefreshing, @@ -228,6 +330,7 @@ private fun EmailList( } } else { LazyColumn( + state = listState, contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -237,7 +340,10 @@ private fun EmailList( ) { email -> MaskedEmailCard( maskedEmail = email, - onClick = { onEmailClick(email) } + onClick = { onEmailClick(email) }, + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope, + modifier = Modifier.animateItem() ) } } diff --git a/app/src/main/java/com/fastmask/ui/navigation/FastMaskNavHost.kt b/app/src/main/java/com/fastmask/ui/navigation/FastMaskNavHost.kt index f483a83..7b82a72 100644 --- a/app/src/main/java/com/fastmask/ui/navigation/FastMaskNavHost.kt +++ b/app/src/main/java/com/fastmask/ui/navigation/FastMaskNavHost.kt @@ -1,5 +1,11 @@ package com.fastmask.ui.navigation +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation.NavHostController @@ -12,62 +18,99 @@ import com.fastmask.ui.create.CreateMaskedEmailScreen import com.fastmask.ui.detail.MaskedEmailDetailScreen import com.fastmask.ui.list.MaskedEmailListScreen +private const val TRANSITION_DURATION_MS = 300 + +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun FastMaskNavHost( navController: NavHostController, startDestination: String, modifier: Modifier = Modifier ) { - NavHost( - navController = navController, - startDestination = startDestination, - modifier = modifier - ) { - composable(NavRoutes.LOGIN) { - LoginScreen( - onLoginSuccess = { - navController.navigate(NavRoutes.EMAIL_LIST) { - popUpTo(NavRoutes.LOGIN) { inclusive = true } - } - } - ) - } - - composable(NavRoutes.EMAIL_LIST) { - MaskedEmailListScreen( - onNavigateToCreate = { - navController.navigate(NavRoutes.CREATE_EMAIL) - }, - onNavigateToDetail = { emailId -> - navController.navigate(NavRoutes.emailDetail(emailId)) - }, - onLogout = { - navController.navigate(NavRoutes.LOGIN) { - popUpTo(0) { inclusive = true } - } - } - ) - } - - composable(NavRoutes.CREATE_EMAIL) { - CreateMaskedEmailScreen( - onNavigateBack = { - navController.popBackStack() - } - ) - } - - composable( - route = NavRoutes.EMAIL_DETAIL, - arguments = listOf( - navArgument("emailId") { type = NavType.StringType } - ) + SharedTransitionLayout { + NavHost( + navController = navController, + startDestination = startDestination, + modifier = modifier, + enterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = tween(TRANSITION_DURATION_MS) + ) + fadeIn(animationSpec = tween(TRANSITION_DURATION_MS)) + }, + exitTransition = { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = tween(TRANSITION_DURATION_MS) + ) + fadeOut(animationSpec = tween(TRANSITION_DURATION_MS)) + }, + popEnterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.End, + animationSpec = tween(TRANSITION_DURATION_MS) + ) + fadeIn(animationSpec = tween(TRANSITION_DURATION_MS)) + }, + popExitTransition = { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.End, + animationSpec = tween(TRANSITION_DURATION_MS) + ) + fadeOut(animationSpec = tween(TRANSITION_DURATION_MS)) + } ) { - MaskedEmailDetailScreen( - onNavigateBack = { - navController.popBackStack() - } - ) + composable( + route = NavRoutes.LOGIN, + enterTransition = { fadeIn(animationSpec = tween(TRANSITION_DURATION_MS)) }, + exitTransition = { fadeOut(animationSpec = tween(TRANSITION_DURATION_MS)) } + ) { + LoginScreen( + onLoginSuccess = { + navController.navigate(NavRoutes.EMAIL_LIST) { + popUpTo(NavRoutes.LOGIN) { inclusive = true } + } + } + ) + } + + composable(NavRoutes.EMAIL_LIST) { + MaskedEmailListScreen( + onNavigateToCreate = { + navController.navigate(NavRoutes.CREATE_EMAIL) + }, + onNavigateToDetail = { emailId -> + navController.navigate(NavRoutes.emailDetail(emailId)) + }, + onLogout = { + navController.navigate(NavRoutes.LOGIN) { + popUpTo(0) { inclusive = true } + } + }, + sharedTransitionScope = this@SharedTransitionLayout, + animatedContentScope = this@composable + ) + } + + composable(NavRoutes.CREATE_EMAIL) { + CreateMaskedEmailScreen( + onNavigateBack = { + navController.popBackStack() + } + ) + } + + composable( + route = NavRoutes.EMAIL_DETAIL, + arguments = listOf( + navArgument("emailId") { type = NavType.StringType } + ) + ) { + MaskedEmailDetailScreen( + onNavigateBack = { + navController.popBackStack() + }, + sharedTransitionScope = this@SharedTransitionLayout, + animatedContentScope = this@composable + ) + } } } } diff --git a/app/src/main/java/com/fastmask/ui/theme/Color.kt b/app/src/main/java/com/fastmask/ui/theme/Color.kt index 7c643e2..718862b 100644 --- a/app/src/main/java/com/fastmask/ui/theme/Color.kt +++ b/app/src/main/java/com/fastmask/ui/theme/Color.kt @@ -2,19 +2,103 @@ package com.fastmask.ui.theme import androidx.compose.ui.graphics.Color -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) - -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) - +// Fastmail Brand Colors val FastmailBlue = Color(0xFF0066CC) val FastmailBlueLight = Color(0xFF4D94DB) val FastmailBlueDark = Color(0xFF004C99) -val EnabledGreen = Color(0xFF4CAF50) -val DisabledGray = Color(0xFF9E9E9E) -val DeletedRed = Color(0xFFE53935) -val PendingOrange = Color(0xFFFF9800) +// M3 Light Theme Colors +val md_theme_light_primary = Color(0xFF0061A4) +val md_theme_light_onPrimary = Color(0xFFFFFFFF) +val md_theme_light_primaryContainer = Color(0xFFD1E4FF) +val md_theme_light_onPrimaryContainer = Color(0xFF001D36) +val md_theme_light_secondary = Color(0xFF535F70) +val md_theme_light_onSecondary = Color(0xFFFFFFFF) +val md_theme_light_secondaryContainer = Color(0xFFD7E3F7) +val md_theme_light_onSecondaryContainer = Color(0xFF101C2B) +val md_theme_light_tertiary = Color(0xFF6B5778) +val md_theme_light_onTertiary = Color(0xFFFFFFFF) +val md_theme_light_tertiaryContainer = Color(0xFFF2DAFF) +val md_theme_light_onTertiaryContainer = Color(0xFF251431) +val md_theme_light_error = Color(0xFFBA1A1A) +val md_theme_light_errorContainer = Color(0xFFFFDAD6) +val md_theme_light_onError = Color(0xFFFFFFFF) +val md_theme_light_onErrorContainer = Color(0xFF410002) +val md_theme_light_background = Color(0xFFFDFCFF) +val md_theme_light_onBackground = Color(0xFF1A1C1E) +val md_theme_light_surface = Color(0xFFFDFCFF) +val md_theme_light_onSurface = Color(0xFF1A1C1E) +val md_theme_light_surfaceVariant = Color(0xFFDFE2EB) +val md_theme_light_onSurfaceVariant = Color(0xFF43474E) +val md_theme_light_outline = Color(0xFF73777F) +val md_theme_light_outlineVariant = Color(0xFFC3C7CF) +val md_theme_light_inverseOnSurface = Color(0xFFF1F0F4) +val md_theme_light_inverseSurface = Color(0xFF2F3033) +val md_theme_light_inversePrimary = Color(0xFF9ECAFF) +val md_theme_light_surfaceTint = Color(0xFF0061A4) +val md_theme_light_scrim = Color(0xFF000000) + +// M3 Surface Containers (Light) +val md_theme_light_surfaceContainerLowest = Color(0xFFFFFFFF) +val md_theme_light_surfaceContainerLow = Color(0xFFF7F6FA) +val md_theme_light_surfaceContainer = Color(0xFFF1F0F4) +val md_theme_light_surfaceContainerHigh = Color(0xFFEBEBEF) +val md_theme_light_surfaceContainerHighest = Color(0xFFE6E5E9) + +// M3 Dark Theme Colors +val md_theme_dark_primary = Color(0xFF9ECAFF) +val md_theme_dark_onPrimary = Color(0xFF003258) +val md_theme_dark_primaryContainer = Color(0xFF00497D) +val md_theme_dark_onPrimaryContainer = Color(0xFFD1E4FF) +val md_theme_dark_secondary = Color(0xFFBBC7DB) +val md_theme_dark_onSecondary = Color(0xFF253140) +val md_theme_dark_secondaryContainer = Color(0xFF3B4858) +val md_theme_dark_onSecondaryContainer = Color(0xFFD7E3F7) +val md_theme_dark_tertiary = Color(0xFFD6BEE4) +val md_theme_dark_onTertiary = Color(0xFF3B2948) +val md_theme_dark_tertiaryContainer = Color(0xFF523F5F) +val md_theme_dark_onTertiaryContainer = Color(0xFFF2DAFF) +val md_theme_dark_error = Color(0xFFFFB4AB) +val md_theme_dark_errorContainer = Color(0xFF93000A) +val md_theme_dark_onError = Color(0xFF690005) +val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val md_theme_dark_background = Color(0xFF1A1C1E) +val md_theme_dark_onBackground = Color(0xFFE2E2E6) +val md_theme_dark_surface = Color(0xFF1A1C1E) +val md_theme_dark_onSurface = Color(0xFFE2E2E6) +val md_theme_dark_surfaceVariant = Color(0xFF43474E) +val md_theme_dark_onSurfaceVariant = Color(0xFFC3C7CF) +val md_theme_dark_outline = Color(0xFF8D9199) +val md_theme_dark_outlineVariant = Color(0xFF43474E) +val md_theme_dark_inverseOnSurface = Color(0xFF1A1C1E) +val md_theme_dark_inverseSurface = Color(0xFFE2E2E6) +val md_theme_dark_inversePrimary = Color(0xFF0061A4) +val md_theme_dark_surfaceTint = Color(0xFF9ECAFF) +val md_theme_dark_scrim = Color(0xFF000000) + +// M3 Surface Containers (Dark) +val md_theme_dark_surfaceContainerLowest = Color(0xFF0F1113) +val md_theme_dark_surfaceContainerLow = Color(0xFF1A1C1E) +val md_theme_dark_surfaceContainer = Color(0xFF1E2022) +val md_theme_dark_surfaceContainerHigh = Color(0xFF282A2D) +val md_theme_dark_surfaceContainerHighest = Color(0xFF333537) + +// Semantic Status Colors - Light Theme (Tonal Containers) +val StatusEnabledContainerLight = Color(0xFFD4F5D0) +val StatusEnabledContentLight = Color(0xFF0D5F0D) +val StatusDisabledContainerLight = Color(0xFFE0E0E0) +val StatusDisabledContentLight = Color(0xFF5C5C5C) +val StatusDeletedContainerLight = Color(0xFFFFDAD6) +val StatusDeletedContentLight = Color(0xFFBA1A1A) +val StatusPendingContainerLight = Color(0xFFFFE0B2) +val StatusPendingContentLight = Color(0xFFB36B00) + +// Semantic Status Colors - Dark Theme (Tonal Containers) +val StatusEnabledContainerDark = Color(0xFF1E4D1E) +val StatusEnabledContentDark = Color(0xFF90EE90) +val StatusDisabledContainerDark = Color(0xFF3D3D3D) +val StatusDisabledContentDark = Color(0xFFB0B0B0) +val StatusDeletedContainerDark = Color(0xFF93000A) +val StatusDeletedContentDark = Color(0xFFFFB4AB) +val StatusPendingContainerDark = Color(0xFF7A4700) +val StatusPendingContentDark = Color(0xFFFFCC80) diff --git a/app/src/main/java/com/fastmask/ui/theme/StatusColors.kt b/app/src/main/java/com/fastmask/ui/theme/StatusColors.kt new file mode 100644 index 0000000..90a909d --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/theme/StatusColors.kt @@ -0,0 +1,66 @@ +package com.fastmask.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +@Immutable +data class StatusColorPair( + val container: Color, + val content: Color +) + +@Immutable +data class StatusColors( + val enabled: StatusColorPair, + val disabled: StatusColorPair, + val deleted: StatusColorPair, + val pending: StatusColorPair +) + +val LightStatusColors = StatusColors( + enabled = StatusColorPair( + container = StatusEnabledContainerLight, + content = StatusEnabledContentLight + ), + disabled = StatusColorPair( + container = StatusDisabledContainerLight, + content = StatusDisabledContentLight + ), + deleted = StatusColorPair( + container = StatusDeletedContainerLight, + content = StatusDeletedContentLight + ), + pending = StatusColorPair( + container = StatusPendingContainerLight, + content = StatusPendingContentLight + ) +) + +val DarkStatusColors = StatusColors( + enabled = StatusColorPair( + container = StatusEnabledContainerDark, + content = StatusEnabledContentDark + ), + disabled = StatusColorPair( + container = StatusDisabledContainerDark, + content = StatusDisabledContentDark + ), + deleted = StatusColorPair( + container = StatusDeletedContainerDark, + content = StatusDeletedContentDark + ), + pending = StatusColorPair( + container = StatusPendingContainerDark, + content = StatusPendingContentDark + ) +) + +val LocalStatusColors = staticCompositionLocalOf { LightStatusColors } + +object FastMaskStatusColors { + val current: StatusColors + @Composable + get() = LocalStatusColors.current +} diff --git a/app/src/main/java/com/fastmask/ui/theme/Theme.kt b/app/src/main/java/com/fastmask/ui/theme/Theme.kt index b37eea0..4aff11e 100644 --- a/app/src/main/java/com/fastmask/ui/theme/Theme.kt +++ b/app/src/main/java/com/fastmask/ui/theme/Theme.kt @@ -1,6 +1,5 @@ package com.fastmask.ui.theme -import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme @@ -9,37 +8,81 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.core.view.WindowCompat private val DarkColorScheme = darkColorScheme( - primary = FastmailBlueLight, - secondary = PurpleGrey80, - tertiary = Pink80, - background = Color(0xFF121212), - surface = Color(0xFF1E1E1E), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color.White, - onSurface = Color.White + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + errorContainer = md_theme_dark_errorContainer, + onError = md_theme_dark_onError, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + outline = md_theme_dark_outline, + outlineVariant = md_theme_dark_outlineVariant, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, + scrim = md_theme_dark_scrim, + surfaceContainerLowest = md_theme_dark_surfaceContainerLowest, + surfaceContainerLow = md_theme_dark_surfaceContainerLow, + surfaceContainer = md_theme_dark_surfaceContainer, + surfaceContainerHigh = md_theme_dark_surfaceContainerHigh, + surfaceContainerHighest = md_theme_dark_surfaceContainerHighest ) private val LightColorScheme = lightColorScheme( - primary = FastmailBlue, - secondary = PurpleGrey40, - tertiary = Pink40, - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F) + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + errorContainer = md_theme_light_errorContainer, + onError = md_theme_light_onError, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + outlineVariant = md_theme_light_outlineVariant, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, + scrim = md_theme_light_scrim, + surfaceContainerLowest = md_theme_light_surfaceContainerLowest, + surfaceContainerLow = md_theme_light_surfaceContainerLow, + surfaceContainer = md_theme_light_surfaceContainer, + surfaceContainerHigh = md_theme_light_surfaceContainerHigh, + surfaceContainerHighest = md_theme_light_surfaceContainerHighest ) @Composable @@ -57,18 +100,15 @@ fun FastMaskTheme( else -> LightColorScheme } - val view = LocalView.current - if (!view.isInEditMode) { - SideEffect { - val window = (view.context as Activity).window - window.statusBarColor = colorScheme.surface.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme - } - } + val statusColors = if (darkTheme) DarkStatusColors else LightStatusColors - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) + CompositionLocalProvider( + LocalStatusColors provides statusColors + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) + } } diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml new file mode 100644 index 0000000..1da3572 --- /dev/null +++ b/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 07bed88..452a969 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -8,4 +8,5 @@ #FF000000 #FFFFFFFF #0066CC + #0066CC diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 6911dbe..1598e7a 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -3,5 +3,12 @@ + + diff --git a/docs/index.md b/docs/index.md index 54a4b48..53d522a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,27 +1,48 @@ --- layout: default title: FastMask - Fastmail Masked Email Manager for Android +description: Native Android app for managing Fastmail masked emails. Create, view, edit, and delete masked email addresses with Material 3 design. --- # FastMask -A native Android app for managing Fastmail masked emails. +**Native Android app for managing Fastmail masked emails** -Create, view, edit, and manage your masked email addresses directly from your Android device. +[![Latest Release](https://img.shields.io/github/v/release/pawelorzech/FastMask?style=flat-square)](https://github.com/pawelorzech/FastMask/releases/latest) +[![License](https://img.shields.io/github/license/pawelorzech/FastMask?style=flat-square)](https://github.com/pawelorzech/FastMask/blob/main/LICENSE) +[![API 26+](https://img.shields.io/badge/API-26%2B-brightgreen?style=flat-square)](https://developer.android.com/about/versions/oreo) + +--- ## Download -**[Download Latest APK](https://github.com/pawelorzech/FastMask/releases/latest)** +Download Latest APK + +Or view all releases on [GitHub](https://github.com/pawelorzech/FastMask/releases) + +--- + +## What is FastMask? + +FastMask lets you manage your [Fastmail](https://www.fastmail.com) masked email addresses directly from your Android phone. + +**Masked emails** are disposable addresses that forward to your real inbox. They help you: +- Protect your real email from spam +- Track which services share your email +- Easily disable addresses if they get compromised ## Features -- **View Masked Emails** - Browse all your Fastmail masked email addresses -- **Create New Masks** - Generate new masked email addresses instantly -- **Enable/Disable** - Toggle masked emails on or off -- **Edit Details** - Update description, domain, and URL -- **Copy to Clipboard** - Quick one-tap copy -- **Search** - Filter your masked emails -- **Material You** - Modern Material 3 design +| Feature | Description | +|---------|-------------| +| **View All Masks** | Browse your masked emails in a clean, searchable list | +| **Create New** | Generate new masked addresses with custom descriptions | +| **Enable/Disable** | Toggle masks on or off without deleting them | +| **Edit Details** | Update description, domain, and URL associations | +| **Quick Copy** | One-tap copy to clipboard | +| **Delete** | Remove masks you no longer need | +| **Search & Filter** | Find specific masks instantly | +| **Material You** | Dynamic theming that adapts to your wallpaper | ## Requirements @@ -30,21 +51,59 @@ Create, view, edit, and manage your masked email addresses directly from your An ## Quick Start -1. Download the APK from [Releases](https://github.com/pawelorzech/FastMask/releases) -2. Install the app on your Android device -3. Create a Fastmail API token at [Fastmail Settings](https://www.fastmail.com/settings/security/tokens) -4. Enter your token in FastMask to start managing your masked emails +### 1. Install the App -## Privacy +Download the APK from [Releases](https://github.com/pawelorzech/FastMask/releases) and install it. -- Your API token is stored securely using Android's EncryptedSharedPreferences -- Direct communication with Fastmail's API - no third-party servers -- No analytics or tracking +### 2. Create a Fastmail API Token -## Source Code +1. Log in to [Fastmail](https://www.fastmail.com) +2. Go to **Settings** → **Privacy & Security** → **Integrations** → **API tokens** +3. Click **New API token** +4. Name it "FastMask" +5. Select scope: **Masked Email** (read/write) +6. Copy the token -FastMask is open source. View the code on [GitHub](https://github.com/pawelorzech/FastMask). +### 3. Log In + +Open FastMask, paste your token, and tap "Log in". --- +## Privacy & Security + +- **Secure Storage**: Your API token is encrypted using Android's EncryptedSharedPreferences +- **Direct Connection**: The app talks directly to Fastmail - no middleman servers +- **No Tracking**: Zero analytics or data collection +- **Open Source**: [Full source code](https://github.com/pawelorzech/FastMask) available for review + +--- + +## Tech Stack + +- **Kotlin** - 100% Kotlin codebase +- **Jetpack Compose** - Modern declarative UI +- **Material 3** - Latest Material Design with dynamic theming +- **JMAP** - Fastmail's native API protocol + +--- + +## Contributing + +FastMask is open source and welcomes contributions! + +- [View Source Code](https://github.com/pawelorzech/FastMask) +- [Report a Bug](https://github.com/pawelorzech/FastMask/issues/new?template=bug_report.md) +- [Request a Feature](https://github.com/pawelorzech/FastMask/issues/new?template=feature_request.md) + +--- + +## License + +FastMask is released under the [MIT License](https://github.com/pawelorzech/FastMask/blob/main/LICENSE). + +--- + +

Made with Kotlin and Jetpack Compose +