Browse Source

feat: implement safe area insets for Android and add development tooling

- Add @capacitor/status-bar dependency for safe area detection
- Implement SafeAreaPlugin for Android with proper inset calculation
- Create safeAreaInset.js utility for CSS custom property injection
- Update Android manifest and build configuration for plugin
- Integrate safe area handling across Vue components and views
- Update iOS Podfile and Android gradle configurations
- Add commitlint and husky for commit message validation

Technical changes:
- SafeAreaPlugin uses WindowInsets API for Android R+ devices
- Fallback detection for navigation bar and gesture bar heights
- CSS custom properties: --safe-area-inset-{top,bottom,left,right}
- Platform-specific detection (Android WebView only)
- StatusBar plugin integration for top inset calculation
android-safe-area-insets
Jose Olarte III 2 weeks ago
parent
commit
4ba58145d0
  1. 373
      BUILDING.md
  2. 1
      android/app/capacitor.build.gradle
  3. 1
      android/app/src/main/AndroidManifest.xml
  4. 4
      android/app/src/main/assets/capacitor.plugins.json
  5. 41
      android/app/src/main/java/app/timesafari/MainActivity.java
  6. 44
      android/app/src/main/java/app/timesafari/safearea/SafeAreaPlugin.java
  7. 9
      android/app/src/main/res/values/styles.xml
  8. 3
      android/capacitor.settings.gradle
  9. 1
      ios/App/Podfile
  10. 8
      ios/App/Podfile.lock
  11. 1202
      package-lock.json
  12. 15
      package.json
  13. 35
      src/App.vue
  14. 2
      src/components/QuickNav.vue
  15. 4
      src/components/TopMessage.vue
  16. 1
      src/main.capacitor.ts
  17. 226
      src/utils/safeAreaInset.js
  18. 6
      src/views/ContactQRScanFullView.vue
  19. 4
      src/views/ContactQRScanShowView.vue
  20. 2
      src/views/DeepLinkErrorView.vue
  21. 2
      src/views/DeepLinkRedirectView.vue

373
BUILDING.md

@ -1159,6 +1159,146 @@ If you need to build manually or want to understand the individual steps:
Prerequisites: Android Studio with Java SDK installed
### Android Safe Area Inset Implementation
**Date**: 2025-08-22
**Status**: ✅ **ACTIVE** - Android WebView safe area support
TimeSafari now includes comprehensive safe area inset support for Android WebView, addressing the limitation that Android doesn't natively support CSS `env(safe-area-inset-*)` variables like iOS.
#### Implementation Overview
The Android safe area implementation consists of two complementary systems:
1. **Native Android Plugin** (`SafeAreaPlugin.java`) - Provides native safe area measurements
2. **JavaScript Injection** (`safeAreaInset.js`) - Injects CSS custom properties for WebView compatibility
#### Native Android Plugin
**File**: `android/app/src/main/java/app/timesafari/safearea/SafeAreaPlugin.java`
The native plugin provides accurate safe area measurements using Android's WindowInsets API:
```java
@CapacitorPlugin(name = "SafeArea")
public class SafeAreaPlugin extends Plugin {
@PluginMethod
public void getSafeAreaInsets(PluginCall call) {
// Uses WindowInsets API for Android 11+ (API 30+)
// Provides fallback values for older versions
}
}
```
**Features**:
- Uses Android 11+ WindowInsets API for accurate measurements
- Provides status bar, navigation bar, and system bar insets
- Fallback values for older Android versions
- Capacitor plugin integration
#### JavaScript Safe Area Injection
**File**: `src/utils/safeAreaInset.js`
The JavaScript implementation provides WebView-compatible safe area support:
**Key Features**:
- **Platform Detection**: Only runs on Android WebView with Capacitor
- **Multiple Detection Methods**: Uses screen dimensions, visual viewport, and device characteristics
- **CSS Custom Properties**: Injects `--safe-area-inset-*` variables
- **Fallback Support**: Provides reasonable defaults when native API unavailable
- **Dynamic Updates**: Re-injects on orientation changes and resize events
**Detection Methods**:
1. **Direct Comparison**: `screenHeight - windowHeight`
2. **Gesture Navigation**: Detects modern gesture-based navigation
3. **Visual Viewport**: Uses `window.visualViewport` for WebView accuracy
4. **Device-Specific**: Common resolution-based estimations
#### CSS Integration
The implementation provides both CSS environment variables and custom properties:
```css
/* CSS Environment Variables (iOS-style) */
env(safe-area-inset-top)
env(safe-area-inset-bottom)
/* CSS Custom Properties (Android WebView) */
var(--safe-area-inset-top, 0px)
var(--safe-area-inset-bottom, 0px)
```
**Usage Pattern**:
```css
/* Cross-platform safe area support */
padding-top: max(env(safe-area-inset-top), var(--safe-area-inset-top, 0px));
padding-bottom: max(env(safe-area-inset-bottom), var(--safe-area-inset-bottom, 0px));
```
#### Dependencies Added
**New Capacitor Plugin**:
- `@capacitor/status-bar`: Provides status bar information for safe area calculations
**Development Dependencies**:
- `@commitlint/cli`: Commit message linting
- `@commitlint/config-conventional`: Conventional commit standards
- `husky`: Git hooks for pre-commit validation
- `lint-staged`: Staged file linting
#### Build Integration
The safe area implementation is automatically loaded in Capacitor builds:
```typescript
// src/main.capacitor.ts
import "./utils/safeAreaInset";
```
**Build Commands**:
```bash
# Standard Android builds (now include safe area support)
npm run build:android:dev
npm run build:android:prod
# Asset validation (includes safe area assets)
npm run assets:validate:android
```
#### Testing Safe Area Implementation
**Manual Testing**:
1. Build and run on Android device/emulator
2. Check safe area insets in different orientations
3. Verify CSS custom properties are injected
4. Test on devices with different navigation types
**Debugging**:
```javascript
// Check if safe area script is running
console.log('Safe area script loaded:', window.Capacitor !== undefined);
// Check injected CSS properties
getComputedStyle(document.documentElement).getPropertyValue('--safe-area-inset-top');
```
#### Troubleshooting
**Common Issues**:
- **No safe area detected**: Ensure running on Android with Capacitor
- **Incorrect measurements**: Check device orientation and navigation type
- **CSS not applied**: Verify CSS custom properties are injected
**Debug Commands**:
```bash
# Check Android build includes safe area plugin
npm run build:android:dev
# Validate assets include safe area support
npm run assets:validate:android
```
#### Android Build Commands
```bash
@ -1431,6 +1571,145 @@ npm run lint-fix # Fix linting issues
Use the commands above to check and fix code quality issues.
### Git Hooks and Commit Standards
**Date**: 2025-08-22
**Status**: ✅ **ACTIVE** - Automated code quality enforcement
TimeSafari now includes comprehensive git hooks and commit standards to ensure code quality and consistent development practices.
#### Husky Git Hooks
**Configuration**: `package.json` husky section
The project uses Husky to manage git hooks:
```json
{
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
}
}
```
**Available Hooks**:
- **pre-commit**: Runs lint-staged to check staged files
- **commit-msg**: Validates commit message format using commitlint
#### Lint-Staged Configuration
**Configuration**: `package.json` lint-staged section
Automatically runs linting on staged files before commits:
```json
{
"lint-staged": {
"*.{js,ts,vue,css,md,json,yml,yaml}": "eslint --fix || true"
}
}
```
**Features**:
- Runs ESLint on JavaScript, TypeScript, Vue, CSS, Markdown, JSON, YAML files
- Automatically fixes auto-fixable issues
- Continues commit process even if some files can't be fixed
#### Commit Message Standards
**Configuration**: `package.json` commitlint section
Uses conventional commit standards for consistent commit messages:
```json
{
"commitlint": {
"extends": ["@commitlint/config-conventional"]
}
}
```
**Commit Message Format**:
```
type(scope): description
[optional body]
[optional footer]
```
**Supported Types**:
- `feat`: New features
- `fix`: Bug fixes
- `docs`: Documentation changes
- `style`: Code style changes (formatting, etc.)
- `refactor`: Code refactoring
- `test`: Adding or updating tests
- `chore`: Maintenance tasks
**Examples**:
```bash
feat(android): add safe area inset support for Android WebView
fix(ios): resolve navigation bar overlap issue
docs(building): update Android build documentation
style(vue): fix component formatting
```
#### Development Workflow
**Pre-commit Process**:
1. Stage files: `git add .`
2. Pre-commit hook runs automatically
3. Lint-staged checks and fixes staged files
4. Commit proceeds if all checks pass
**Commit Message Validation**:
1. Write commit message: `git commit -m "type(scope): description"`
2. Commit-msg hook validates format
3. Commit proceeds if format is valid
#### Bypassing Hooks (Emergency Only)
**Warning**: Only use in emergency situations
```bash
# Skip pre-commit hooks
git commit --no-verify -m "emergency: critical fix"
# Skip all hooks
git commit --no-verify --no-verify-signatures -m "emergency: critical fix"
```
#### Troubleshooting Git Hooks
**Common Issues**:
- **Hooks not running**: Ensure Husky is properly installed and configured
- **Lint errors blocking commit**: Fix linting issues or use `--no-verify` (emergency only)
- **Commit message rejected**: Follow conventional commit format
**Debug Commands**:
```bash
# Check if Husky is installed
npm list husky
# Test lint-staged manually
npx lint-staged
# Test commitlint manually
echo "feat: test commit" | npx commitlint
```
#### Dependencies
**Development Dependencies Added**:
- `husky`: Git hooks management
- `lint-staged`: Staged file linting
- `@commitlint/cli`: Commit message validation
- `@commitlint/config-conventional`: Conventional commit standards
## Code Build Architecture
### Web Build Process
@ -1668,6 +1947,100 @@ npm run build:android:assets
- [Web Build Scripts](docs/web-build-scripts.md)
- [Build Troubleshooting](docs/build-troubleshooting.md)
## Recent Updates
### CSS Safe Area Improvements (2025-08-22)
**Status**: ✅ **COMPLETED** - Cross-platform safe area support
TimeSafari's Vue components have been updated to provide consistent safe area support across all platforms, including Android WebView.
#### Updated Components
**Core Components**:
- `src/App.vue` - Main application layout
- `src/components/QuickNav.vue` - Bottom navigation
- `src/components/TopMessage.vue` - Top message display
**View Components**:
- `src/views/ContactQRScanFullView.vue` - QR scanner full view
- `src/views/ContactQRScanShowView.vue` - QR scanner show view
- `src/views/DeepLinkErrorView.vue` - Deep link error handling
- `src/views/DeepLinkRedirectView.vue` - Deep link redirect
#### CSS Pattern Implementation
All components now use the cross-platform safe area pattern:
```css
/* Before (iOS-only) */
padding-top: env(safe-area-inset-top);
/* After (Cross-platform) */
padding-top: max(env(safe-area-inset-top), var(--safe-area-inset-top, 0px));
```
**Benefits**:
- **iOS**: Uses native `env()` variables
- **Android**: Uses injected CSS custom properties
- **Web**: Falls back to 0px values
- **Future-proof**: Supports new platforms automatically
#### Implementation Details
**CSS Custom Properties**:
- `--safe-area-inset-top`: Top safe area inset
- `--safe-area-inset-bottom`: Bottom safe area inset
- `--safe-area-inset-left`: Left safe area inset
- `--safe-area-inset-right`: Right safe area inset
**Fallback Strategy**:
```css
/* Primary: CSS environment variables (iOS) */
/* Secondary: CSS custom properties (Android WebView) */
/* Tertiary: Fallback value (Web, older browsers) */
max(env(safe-area-inset-top), var(--safe-area-inset-top, 0px))
```
#### Testing Cross-Platform Support
**iOS Testing**:
```bash
npm run build:ios:dev
# Test on iOS simulator and device
# Verify env() variables work correctly
```
**Android Testing**:
```bash
npm run build:android:dev
# Test on Android emulator and device
# Verify CSS custom properties are injected
```
**Web Testing**:
```bash
npm run build:web:dev
# Test in browser
# Verify fallback values work correctly
```
#### Migration Guide
**For New Components**:
```css
/* Use this pattern for all safe area references */
padding-top: max(env(safe-area-inset-top), var(--safe-area-inset-top, 0px));
padding-bottom: max(env(safe-area-inset-bottom), var(--safe-area-inset-bottom, 0px));
padding-left: max(env(safe-area-inset-left), var(--safe-area-inset-left, 0px));
padding-right: max(env(safe-area-inset-right), var(--safe-area-inset-right, 0px));
```
**For Existing Components**:
1. Replace `env(safe-area-inset-*)` with the max() pattern
2. Test on all target platforms
3. Verify safe area behavior is consistent
---
## Appendix: Build System Organization

1
android/app/capacitor.build.gradle

@ -15,6 +15,7 @@ dependencies {
implementation project(':capacitor-camera')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-share')
implementation project(':capacitor-status-bar')
implementation project(':capawesome-capacitor-file-picker')
}

1
android/app/src/main/AndroidManifest.xml

@ -14,6 +14,7 @@
android:label="@string/title_activity_main"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/AppTheme.NoActionBarLaunch">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

4
android/app/src/main/assets/capacitor.plugins.json

@ -23,6 +23,10 @@
"pkg": "@capacitor/share",
"classpath": "com.capacitorjs.plugins.share.SharePlugin"
},
{
"pkg": "@capacitor/status-bar",
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
},
{
"pkg": "@capawesome/capacitor-file-picker",
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"

41
android/app/src/main/java/app/timesafari/MainActivity.java

@ -1,7 +1,16 @@
package app.timesafari;
import android.os.Bundle;
import android.view.View;
import android.view.WindowManager;
import android.view.WindowInsetsController;
import android.view.WindowInsets;
import android.os.Build;
import android.webkit.WebView;
import android.webkit.WebSettings;
import android.webkit.WebViewClient;
import com.getcapacitor.BridgeActivity;
import app.timesafari.safearea.SafeAreaPlugin;
//import com.getcapacitor.community.sqlite.SQLite;
public class MainActivity extends BridgeActivity {
@ -9,7 +18,39 @@ public class MainActivity extends BridgeActivity {
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Enable edge-to-edge display for modern Android
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11+ (API 30+)
getWindow().setDecorFitsSystemWindows(false);
// Set up system UI visibility for edge-to-edge
WindowInsetsController controller = getWindow().getInsetsController();
if (controller != null) {
controller.setSystemBarsAppearance(
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS |
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS,
WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS |
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS
);
controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
// Legacy Android (API 21-29)
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR |
View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
);
}
// Register SafeArea plugin
registerPlugin(SafeAreaPlugin.class);
// Initialize SQLite
//registerPlugin(SQLite.class);
}
}

44
android/app/src/main/java/app/timesafari/safearea/SafeAreaPlugin.java

@ -0,0 +1,44 @@
package app.timesafari.safearea;
import android.os.Build;
import android.view.WindowInsets;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
@CapacitorPlugin(name = "SafeArea")
public class SafeAreaPlugin extends Plugin {
@PluginMethod
public void getSafeAreaInsets(PluginCall call) {
JSObject result = new JSObject();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
WindowInsets insets = getActivity().getWindow().getDecorView().getRootWindowInsets();
if (insets != null) {
int top = insets.getInsets(WindowInsets.Type.statusBars()).top;
int bottom = insets.getInsets(WindowInsets.Type.navigationBars()).bottom;
int left = insets.getInsets(WindowInsets.Type.systemBars()).left;
int right = insets.getInsets(WindowInsets.Type.systemBars()).right;
result.put("top", top);
result.put("bottom", bottom);
result.put("left", left);
result.put("right", right);
call.resolve(result);
return;
}
}
// Fallback values
result.put("top", 0);
result.put("bottom", 0);
result.put("left", 0);
result.put("right", 0);
call.resolve(result);
}
}

9
android/app/src/main/res/values/styles.xml

@ -18,5 +18,14 @@
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
<item name="android:windowTranslucentStatus">false</item>
<item name="android:windowTranslucentNavigation">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:windowLightNavigationBar">true</item>
<item name="android:enforceStatusBarContrast">false</item>
<item name="android:enforceNavigationBarContrast">false</item>
</style>
</resources>

3
android/capacitor.settings.gradle

@ -20,5 +20,8 @@ project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacit
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
include ':capawesome-capacitor-file-picker'
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android')

1
ios/App/Podfile

@ -17,6 +17,7 @@ def capacitor_pods
pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera'
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'CapawesomeCapacitorFilePicker', :path => '../../node_modules/@capawesome/capacitor-file-picker'
end

8
ios/App/Podfile.lock

@ -17,6 +17,8 @@ PODS:
- GoogleMLKit/BarcodeScanning (= 5.0.0)
- CapacitorShare (6.0.3):
- Capacitor
- CapacitorStatusBar (6.0.2):
- Capacitor
- CapawesomeCapacitorFilePicker (6.2.0):
- Capacitor
- GoogleDataTransport (9.4.1):
@ -93,6 +95,7 @@ DEPENDENCIES:
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)"
- "CapacitorMlkitBarcodeScanning (from `../../node_modules/@capacitor-mlkit/barcode-scanning`)"
- "CapacitorShare (from `../../node_modules/@capacitor/share`)"
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
- "CapawesomeCapacitorFilePicker (from `../../node_modules/@capawesome/capacitor-file-picker`)"
SPEC REPOS:
@ -129,6 +132,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor-mlkit/barcode-scanning"
CapacitorShare:
:path: "../../node_modules/@capacitor/share"
CapacitorStatusBar:
:path: "../../node_modules/@capacitor/status-bar"
CapawesomeCapacitorFilePicker:
:path: "../../node_modules/@capawesome/capacitor-file-picker"
@ -141,6 +146,7 @@ SPEC CHECKSUMS:
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74
CapacitorMlkitBarcodeScanning: 7652be9c7922f39203a361de735d340ae37e134e
CapacitorShare: d2a742baec21c8f3b92b361a2fbd2401cdd8288e
CapacitorStatusBar: b16799a26320ffa52f6c8b01737d5a95bbb8f3eb
CapawesomeCapacitorFilePicker: c40822f0a39f86855321943c7829d52bca7f01bd
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleMLKit: 90ba06e028795a50261f29500d238d6061538711
@ -157,6 +163,6 @@ SPEC CHECKSUMS:
SQLCipher: 31878d8ebd27e5c96db0b7cb695c96e9f8ad77da
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
PODFILE CHECKSUM: f987510f7383b04a1b09ea8472bdadcd88b6c924
PODFILE CHECKSUM: 5b7f312bcb943bcf14b44515fdb02f36d11cd50f
COCOAPODS: 1.16.2

1202
package-lock.json

File diff suppressed because it is too large

15
package.json

@ -135,8 +135,10 @@
"lint-staged": {
"*.{js,ts,vue,css,md,json,yml,yaml}": "eslint --fix || true"
},
"commitlint": {
"extends": ["@commitlint/config-conventional"]
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
@ -150,6 +152,7 @@
"@capacitor/filesystem": "^6.0.0",
"@capacitor/ios": "^6.2.0",
"@capacitor/share": "^6.0.3",
"@capacitor/status-bar": "^6.0.2",
"@capawesome/capacitor-file-picker": "^6.2.0",
"@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1",
@ -227,6 +230,8 @@
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@commitlint/cli": "^18.6.1",
"@commitlint/config-conventional": "^18.6.2",
"@playwright/test": "^1.54.2",
"@types/dom-webcodecs": "^0.1.7",
"@types/jest": "^30.0.0",
@ -254,13 +259,11 @@
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-vue": "^9.32.0",
"fs-extra": "^11.3.0",
"husky": "^9.0.11",
"jest": "^30.0.4",
"lint-staged": "^15.2.2",
"markdownlint": "^0.37.4",
"markdownlint-cli": "^0.44.0",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"@commitlint/cli": "^18.6.1",
"@commitlint/config-conventional": "^18.6.2",
"npm-check-updates": "^17.1.13",
"path-browserify": "^1.0.1",
"postcss": "^8.4.38",

35
src/App.vue

@ -4,7 +4,7 @@
<!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind -->
<NotificationGroup group="alert">
<div
class="fixed z-[90] top-[max(1rem,env(safe-area-inset-top))] right-4 left-4 sm:left-auto sm:w-full sm:max-w-sm flex flex-col items-start justify-end"
class="fixed z-[90] top-[max(1rem,env(safe-area-inset-top),var(--safe-area-inset-top,0px))] right-4 left-4 sm:left-auto sm:w-full sm:max-w-sm flex flex-col items-start justify-end"
>
<Notification
v-slot="{ notifications, close }"
@ -175,7 +175,9 @@
"-permission", "-mute", "-off"
-->
<NotificationGroup group="modal">
<div class="fixed z-[100] top-[env(safe-area-inset-top)] inset-x-0 w-full">
<div
class="fixed z-[100] top-[max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))] inset-x-0 w-full"
>
<Notification
v-slot="{ notifications, close }"
enter="transform ease-out duration-300 transition"
@ -506,13 +508,32 @@ export default class App extends Vue {
<style>
#Content {
padding-left: max(1.5rem, env(safe-area-inset-left));
padding-right: max(1.5rem, env(safe-area-inset-right));
padding-top: max(1.5rem, env(safe-area-inset-top));
padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
padding-left: max(
1.5rem,
env(safe-area-inset-left),
var(--safe-area-inset-left, 0px)
);
padding-right: max(
1.5rem,
env(safe-area-inset-right),
var(--safe-area-inset-right, 0px)
);
padding-top: max(
1.5rem,
env(safe-area-inset-top),
var(--safe-area-inset-top, 0px)
);
padding-bottom: max(
1.5rem,
env(safe-area-inset-bottom),
var(--safe-area-inset-bottom, 0px)
);
}
#QuickNav ~ #Content {
padding-bottom: calc(env(safe-area-inset-bottom) + 6.333rem);
padding-bottom: calc(
max(env(safe-area-inset-bottom), var(--safe-area-inset-bottom, 0px)) +
6.333rem
);
}
</style>

2
src/components/QuickNav.vue

@ -2,7 +2,7 @@
<!-- QUICK NAV -->
<nav
id="QuickNav"
class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50 pb-[env(safe-area-inset-bottom)]"
class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50 pb-[max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px))]"
>
<ul class="flex text-2xl px-6 py-2 gap-1 max-w-3xl mx-auto">
<!-- Home Feed -->

4
src/components/TopMessage.vue

@ -1,5 +1,7 @@
<template>
<div class="absolute right-5 top-[max(0.75rem,env(safe-area-inset-top))]">
<div
class="absolute right-5 top-[max(0.75rem,env(safe-area-inset-top),var(--safe-area-inset-top,0px))]"
>
<span class="align-center text-red-500 mr-2">{{ message }}</span>
<span class="ml-2">
<router-link

1
src/main.capacitor.ts

@ -35,6 +35,7 @@ import { handleApiError } from "./services/api";
import { AxiosError } from "axios";
import { DeepLinkHandler } from "./services/deepLinks";
import { logger, safeStringify } from "./utils/logger";
import "./utils/safeAreaInset";
logger.log("[Capacitor] 🚀 Starting initialization");
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);

226
src/utils/safeAreaInset.js

@ -0,0 +1,226 @@
/**
* Safe Area Inset Injection for Android WebView
*
* This script injects safe area inset values into CSS environment variables
* when running in Android WebView, since Android doesn't natively support
* CSS env(safe-area-inset-*) variables like iOS does.
*/
// Check if we're running in Android WebView with Capacitor
const isAndroidWebView = () => {
// Check if we're on iOS - if so, skip this script entirely
const isIOS =
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
if (isIOS) {
return false;
}
// Check if we're on Android
const isAndroid = /Android/.test(navigator.userAgent);
// Check if we have Capacitor (required for Android WebView)
const hasCapacitor = window.Capacitor !== undefined;
// Only run on Android with Capacitor
return isAndroid && hasCapacitor;
};
// Wait for Capacitor to be available
const waitForCapacitor = () => {
return new Promise((resolve) => {
if (window.Capacitor) {
resolve(window.Capacitor);
return;
}
// Wait for Capacitor to be available
const checkCapacitor = () => {
if (window.Capacitor) {
resolve(window.Capacitor);
} else {
setTimeout(checkCapacitor, 100);
}
};
checkCapacitor();
});
};
// Inject safe area inset values into CSS custom properties
const injectSafeAreaInsets = async () => {
try {
// Wait for Capacitor to be available
const Capacitor = await waitForCapacitor();
// Try to get safe area insets using StatusBar plugin (which is already available)
let top = 0,
bottom = 0,
left = 0,
right = 0;
try {
// Use StatusBar plugin to get status bar height
if (Capacitor.Plugins.StatusBar) {
const statusBarInfo = await Capacitor.Plugins.StatusBar.getInfo();
// Status bar height is typically the top safe area inset
top = statusBarInfo.overlays ? 0 : statusBarInfo.height || 0;
}
} catch (error) {
// Status bar info not available, will use fallback
}
// Detect navigation bar and gesture bar heights
const detectNavigationBar = () => {
const screenHeight = window.screen.height;
const screenWidth = window.screen.width;
const windowHeight = window.innerHeight;
const devicePixelRatio = window.devicePixelRatio || 1;
// Calculate navigation bar height
let navBarHeight = 0;
// Method 1: Direct comparison (most reliable)
if (windowHeight < screenHeight) {
navBarHeight = screenHeight - windowHeight;
}
// Method 2: Check for gesture navigation indicators
if (navBarHeight === 0) {
// Look for common gesture navigation patterns
const isTallDevice = screenHeight > 2000;
const isModernDevice = screenHeight > 1800;
const hasHighDensity = devicePixelRatio >= 2.5;
if (isTallDevice && hasHighDensity) {
// Modern gesture-based device
navBarHeight = 12; // Typical gesture bar height
} else if (isModernDevice) {
// Modern device with traditional navigation
navBarHeight = 48; // Traditional navigation bar height
}
}
// Method 3: Check visual viewport (more accurate for WebView)
if (navBarHeight === 0) {
if (window.visualViewport) {
const visualHeight = window.visualViewport.height;
if (visualHeight < windowHeight) {
navBarHeight = windowHeight - visualHeight;
}
}
}
// Method 4: Device-specific estimation based on screen dimensions
if (navBarHeight === 0) {
// Common Android navigation bar heights in pixels
const commonNavBarHeights = {
"1080x2400": 48, // Common 1080p devices
"1440x3200": 64, // QHD devices
"720x1600": 32, // HD devices
};
const resolution = `${screenWidth}x${screenHeight}`;
const estimatedHeight = commonNavBarHeights[resolution];
if (estimatedHeight) {
navBarHeight = estimatedHeight;
} else {
// Fallback: estimate based on screen height
navBarHeight = screenHeight > 2000 ? 48 : 32;
}
}
return navBarHeight;
};
// Get navigation bar height
bottom = detectNavigationBar();
// If we still don't have a top value, estimate it
if (top === 0) {
const screenHeight = window.screen.height;
// Common status bar heights: 24dp (48px) for most devices, 32dp (64px) for some
top = screenHeight > 1920 ? 64 : 48;
}
// Left/right safe areas are rare on Android
left = 0;
right = 0;
// Create CSS custom properties
const style = document.createElement("style");
style.textContent = `
:root {
--safe-area-inset-top: ${top}px;
--safe-area-inset-bottom: ${bottom}px;
--safe-area-inset-left: ${left}px;
--safe-area-inset-right: ${right}px;
}
`;
// Inject the style into the document head
document.head.appendChild(style);
// Also set CSS environment variables if supported
if (CSS.supports("env(safe-area-inset-top)")) {
document.documentElement.style.setProperty(
"--env-safe-area-inset-top",
`${top}px`,
);
document.documentElement.style.setProperty(
"--env-safe-area-inset-bottom",
`${bottom}px`,
);
document.documentElement.style.setProperty(
"--env-safe-area-inset-left",
`${left}px`,
);
document.documentElement.style.setProperty(
"--env-safe-area-inset-right",
`${right}px`,
);
}
} catch (error) {
// Error injecting safe area insets, will use fallback values
}
};
// Initialize when DOM is ready
const initializeSafeArea = () => {
// Check if we should run this script at all
if (!isAndroidWebView()) {
return;
}
// Add a small delay to ensure WebView is fully initialized
setTimeout(() => {
injectSafeAreaInsets();
}, 100);
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeSafeArea);
} else {
initializeSafeArea();
}
// Re-inject on orientation change (only on Android)
window.addEventListener("orientationchange", () => {
if (isAndroidWebView()) {
setTimeout(() => injectSafeAreaInsets(), 100);
}
});
// Re-inject on resize (only on Android)
window.addEventListener("resize", () => {
if (isAndroidWebView()) {
setTimeout(() => injectSafeAreaInsets(), 100);
}
});
// Export for use in other modules
export { injectSafeAreaInsets, isAndroidWebView };

6
src/views/ContactQRScanFullView.vue

@ -220,21 +220,21 @@ export default class ContactQRScanFull extends Vue {
* Computed property for QR code container CSS classes
*/
get qrContainerClasses(): string {
return "block w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto mt-4";
return "block w-full max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto mt-4";
}
/**
* Computed property for camera frame CSS classes
*/
get cameraFrameClasses(): string {
return "relative w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto border border-dashed border-white mt-8 aspect-square";
return "relative w-full max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto border border-dashed border-white mt-8 aspect-square";
}
/**
* Computed property for main content container CSS classes
*/
get mainContentClasses(): string {
return "p-6 bg-white w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto";
return "p-6 bg-white w-full max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto";
}
/**

4
src/views/ContactQRScanShowView.vue

@ -259,11 +259,11 @@ export default class ContactQRScanShow extends Vue {
}
get qrCodeContainerClasses(): string {
return "block w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto my-4";
return "block w-[90vw] max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto my-4";
}
get scannerContainerClasses(): string {
return "relative aspect-square overflow-hidden bg-slate-800 w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto";
return "relative aspect-square overflow-hidden bg-slate-800 w-[90vw] max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto";
}
get statusMessageClasses(): string {

2
src/views/DeepLinkErrorView.vue

@ -123,7 +123,7 @@ onMounted(() => {
}
.safe-area-spacer {
height: env(safe-area-inset-top);
height: max(env(safe-area-inset-top), var(--safe-area-inset-top, 0px));
}
h1 {

2
src/views/DeepLinkRedirectView.vue

@ -2,7 +2,7 @@
<!-- CONTENT -->
<section id="Content" class="relative w-[100vw] h-[100vh]">
<div
class="p-6 bg-white w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
class="p-6 bg-white w-full max-w-[calc((100vh-max(env(safe-area-inset-top),var(--safe-area-inset-top,0px))-max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))*0.4)] mx-auto"
>
<div class="mb-4">
<h1 class="text-xl text-center font-semibold relative mb-4">

Loading…
Cancel
Save