From 76997e807cda2927375d4e0360f99a003b2defcc Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 4 Jan 2026 21:49:28 +0100 Subject: [PATCH] Add new item type default icons and placeholders (#1404) --- .../app/autofill/AutofillService.kt | 29 +- .../net/aliasvault/app/utils/ItemTypeIcon.kt | 356 ++++++++++++++++++ apps/mobile-app/app/(tabs)/items/[id].tsx | 2 +- apps/mobile-app/app/(tabs)/items/deleted.tsx | 23 +- apps/mobile-app/components/items/ItemCard.tsx | 3 +- apps/mobile-app/components/items/ItemIcon.tsx | 274 +++++++++++++- ...rviceLogoView.swift => ItemLogoView.swift} | 114 ++++-- .../ios/VaultUI/Components/ItemTypeIcon.swift | 159 ++++++++ .../Components/CredentialCardView.swift | 2 +- 9 files changed, 892 insertions(+), 70 deletions(-) create mode 100644 apps/mobile-app/android/app/src/main/java/net/aliasvault/app/utils/ItemTypeIcon.kt rename apps/mobile-app/ios/VaultUI/Components/{ServiceLogoView.swift => ItemLogoView.swift} (54%) create mode 100644 apps/mobile-app/ios/VaultUI/Components/ItemTypeIcon.swift diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt index 261eb7530..8d889d9d3 100644 --- a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/autofill/AutofillService.kt @@ -30,6 +30,7 @@ import net.aliasvault.app.autofill.models.FieldType import net.aliasvault.app.autofill.utils.CredentialMatcher import net.aliasvault.app.autofill.utils.FieldFinder import net.aliasvault.app.autofill.utils.ImageUtils +import net.aliasvault.app.utils.ItemTypeIcon import net.aliasvault.app.vaultstore.VaultStore import net.aliasvault.app.vaultstore.interfaces.CredentialOperationCallback import net.aliasvault.app.vaultstore.models.Credential @@ -250,12 +251,8 @@ class AutofillService : AutofillService() { * @return The dataset */ private fun createCredentialDataset(fieldFinder: FieldFinder, credential: Credential): Dataset { - // Choose layout based on whether we have a logo - val layoutId = if (credential.service.logo != null) { - R.layout.autofill_dataset_item_icon - } else { - R.layout.autofill_dataset_item - } + // Always use icon layout (will show logo or placeholder icon) + val layoutId = R.layout.autofill_dataset_item_icon // Create presentation for this credential using our custom layout val presentation = RemoteViews(packageName, layoutId) @@ -366,13 +363,21 @@ class AutofillService : AutofillService() { presentationDisplayValue, ) - // Set the logo if available + // Set the logo if available, otherwise use placeholder icon val logoBytes = credential.service.logo - if (logoBytes != null) { - val bitmap = ImageUtils.bytesToBitmap(logoBytes) - if (bitmap != null) { - presentation.setImageViewBitmap(R.id.icon, bitmap) - } + val bitmap = if (logoBytes != null) { + ImageUtils.bytesToBitmap(logoBytes) + } else { + // Use placeholder key icon for Login/Alias items + ItemTypeIcon.getIcon( + context = this@AutofillService, + itemType = ItemTypeIcon.ItemType.LOGIN, + size = 96, + ) + } + + if (bitmap != null) { + presentation.setImageViewBitmap(R.id.icon, bitmap) } return dataSetBuilder.build() diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/utils/ItemTypeIcon.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/utils/ItemTypeIcon.kt new file mode 100644 index 000000000..2fcffe23d --- /dev/null +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/utils/ItemTypeIcon.kt @@ -0,0 +1,356 @@ +package net.aliasvault.app.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.graphics.Typeface + +/** + * Item type icon helper - provides bitmap-based icons for different item types + * Matches the design from browser extension and iOS implementations + */ +object ItemTypeIcon { + + /** + * Item type enumeration matching the database model + */ + object ItemType { + const val LOGIN = "Login" + const val ALIAS = "Alias" + const val CREDIT_CARD = "CreditCard" + const val NOTE = "Note" + } + + /** + * Credit card brand type + */ + enum class CardBrand { + VISA, + MASTERCARD, + AMEX, + DISCOVER, + GENERIC, + ; + + companion object { + /** + * Detect credit card brand from card number using industry-standard prefixes + * @param cardNumber The card number to detect brand from + * @return The detected card brand + */ + fun detect(cardNumber: String?): CardBrand { + if (cardNumber.isNullOrEmpty()) { + return GENERIC + } + + // Remove spaces and dashes + val cleaned = cardNumber.replace(Regex("[\\s-]"), "") + + // Must be mostly numeric + if (!cleaned.matches(Regex("^\\d{4,}.*"))) { + return GENERIC + } + + // Visa: starts with 4 + if (cleaned.matches(Regex("^4.*"))) { + return VISA + } + + // Mastercard: starts with 51-55 or 2221-2720 + if (cleaned.matches(Regex("^5[1-5].*")) || cleaned.matches(Regex("^2[2-7].*"))) { + return MASTERCARD + } + + // Amex: starts with 34 or 37 + if (cleaned.matches(Regex("^3[47].*"))) { + return AMEX + } + + // Discover: starts with 6011, 622, 644-649, 65 + if (cleaned.matches(Regex("^6(?:011|22|4[4-9]|5).*"))) { + return DISCOVER + } + + return GENERIC + } + } + } + + // AliasVault color scheme + private const val COLOR_PRIMARY = "#f49541" + private const val COLOR_DARK = "#d68338" + private const val COLOR_LIGHT = "#ffe096" + private const val COLOR_LIGHTER = "#fbcb74" + + /** + * Get the appropriate icon bitmap for an item type + * @param context Android context + * @param itemType The item type (Login, Alias, CreditCard, Note) + * @param cardNumber Optional card number for credit card brand detection + * @param size Icon size in pixels (default 96) + * @return Bitmap icon + */ + fun getIcon( + context: Context, + itemType: String, + cardNumber: String? = null, + size: Int = 96, + ): Bitmap { + return when (itemType) { + ItemType.NOTE -> createNoteIcon(size) + ItemType.CREDIT_CARD -> { + val brand = CardBrand.detect(cardNumber) + getCardIcon(brand, size) + } + ItemType.LOGIN, ItemType.ALIAS -> createPlaceholderIcon(size) + else -> createPlaceholderIcon(size) + } + } + + /** + * Get the appropriate icon for a credit card brand + */ + fun getCardIcon(brand: CardBrand, size: Int = 96): Bitmap { + return when (brand) { + CardBrand.VISA -> createVisaIcon(size) + CardBrand.MASTERCARD -> createMastercardIcon(size) + CardBrand.AMEX -> createAmexIcon(size) + CardBrand.DISCOVER -> createDiscoverIcon(size) + CardBrand.GENERIC -> createCreditCardIcon(size) + } + } + + /** + * Create generic credit card icon + */ + private fun createCreditCardIcon(size: Int): Bitmap { + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + val scale = size / 32f + + // Card background + paint.color = Color.parseColor(COLOR_PRIMARY) + canvas.drawRoundRect( + RectF(2 * scale, 6 * scale, 30 * scale, 26 * scale), + 3 * scale, + 3 * scale, + paint, + ) + + // Magnetic stripe + paint.color = Color.parseColor(COLOR_DARK) + canvas.drawRect(2 * scale, 11 * scale, 30 * scale, 15 * scale, paint) + + // Chip + paint.color = Color.parseColor(COLOR_LIGHT) + canvas.drawRoundRect( + RectF(5 * scale, 18 * scale, 13 * scale, 20 * scale), + 1 * scale, + 1 * scale, + paint, + ) + + // Number line + paint.color = Color.parseColor(COLOR_LIGHTER) + canvas.drawRoundRect( + RectF(5 * scale, 22 * scale, 10 * scale, 23.5f * scale), + 0.75f * scale, + 0.75f * scale, + paint, + ) + + return bitmap + } + + /** + * Create Visa icon + */ + private fun createVisaIcon(size: Int): Bitmap { + val bitmap = createCreditCardIcon(size) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.color = Color.parseColor(COLOR_LIGHT) + paint.textSize = 6 * (size / 32f) + paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + + canvas.drawText("VISA", 8 * (size / 32f), 18 * (size / 32f), paint) + + return bitmap + } + + /** + * Create Mastercard icon + */ + private fun createMastercardIcon(size: Int): Bitmap { + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + val scale = size / 32f + + // Card background + paint.color = Color.parseColor(COLOR_PRIMARY) + canvas.drawRoundRect( + RectF(2 * scale, 6 * scale, 30 * scale, 26 * scale), + 3 * scale, + 3 * scale, + paint, + ) + + // Left circle + paint.color = Color.parseColor(COLOR_DARK) + canvas.drawCircle(13 * scale, 16 * scale, 5 * scale, paint) + + // Right circle + paint.color = Color.parseColor(COLOR_LIGHT) + canvas.drawCircle(19 * scale, 16 * scale, 5 * scale, paint) + + // Overlap (simplified) + paint.color = Color.parseColor(COLOR_LIGHTER) + val path = Path() + path.addCircle(16 * scale, 16 * scale, 3.5f * scale, Path.Direction.CW) + canvas.drawPath(path, paint) + + return bitmap + } + + /** + * Create Amex icon + */ + private fun createAmexIcon(size: Int): Bitmap { + val bitmap = createCreditCardIcon(size) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.color = Color.parseColor(COLOR_LIGHT) + paint.textSize = 8 * (size / 32f) + paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + paint.textAlign = Paint.Align.CENTER + + canvas.drawText("AMEX", 16 * (size / 32f), 18 * (size / 32f), paint) + + return bitmap + } + + /** + * Create Discover icon + */ + private fun createDiscoverIcon(size: Int): Bitmap { + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + val scale = size / 32f + + // Card background + paint.color = Color.parseColor(COLOR_PRIMARY) + canvas.drawRoundRect( + RectF(2 * scale, 6 * scale, 30 * scale, 26 * scale), + 3 * scale, + 3 * scale, + paint, + ) + + // Circle logo + paint.color = Color.parseColor(COLOR_LIGHT) + canvas.drawCircle(20 * scale, 16 * scale, 4 * scale, paint) + + // "DI" text + paint.textSize = 6 * scale + paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + canvas.drawText("D", 7 * scale, 17 * scale, paint) + + return bitmap + } + + /** + * Create note/document icon + */ + private fun createNoteIcon(size: Int): Bitmap { + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + val scale = size / 32f + + // Document body + paint.color = Color.parseColor(COLOR_PRIMARY) + val path = Path() + path.moveTo(8 * scale, 4 * scale) + path.lineTo(19 * scale, 4 * scale) + path.lineTo(26 * scale, 11 * scale) + path.lineTo(26 * scale, 26 * scale) + path.lineTo(8 * scale, 26 * scale) + path.close() + canvas.drawPath(path, paint) + + // Folded corner + paint.color = Color.parseColor(COLOR_DARK) + val cornerPath = Path() + cornerPath.moveTo(19 * scale, 4 * scale) + cornerPath.lineTo(19 * scale, 11 * scale) + cornerPath.lineTo(26 * scale, 11 * scale) + cornerPath.close() + canvas.drawPath(cornerPath, paint) + + // Text lines + paint.color = Color.parseColor(COLOR_LIGHT) + canvas.drawRoundRect( + RectF(10 * scale, 14 * scale, 22 * scale, 15.5f * scale), + 0.75f * scale, + 0.75f * scale, + paint, + ) + canvas.drawRoundRect( + RectF(10 * scale, 18 * scale, 20 * scale, 19.5f * scale), + 0.75f * scale, + 0.75f * scale, + paint, + ) + canvas.drawRoundRect( + RectF(10 * scale, 22 * scale, 18 * scale, 23.5f * scale), + 0.75f * scale, + 0.75f * scale, + paint, + ) + + return bitmap + } + + /** + * Create placeholder key icon for Login/Alias + */ + private fun createPlaceholderIcon(size: Int): Bitmap { + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.color = Color.parseColor(COLOR_PRIMARY) + paint.style = Paint.Style.STROKE + paint.strokeWidth = 2.5f * (size / 32f) + paint.strokeCap = Paint.Cap.ROUND + + val scale = size / 32f + + // Key bow (circular head) + canvas.drawCircle(10 * scale, 10 * scale, 6.5f * scale, paint) + + // Key hole in bow + paint.strokeWidth = 2 * scale + canvas.drawCircle(10 * scale, 10 * scale, 2.5f * scale, paint) + + // Key shaft - diagonal + paint.strokeWidth = 2.5f * scale + canvas.drawLine(15 * scale, 15 * scale, 27 * scale, 27 * scale, paint) + + // Key teeth - perpendicular to shaft + canvas.drawLine(19 * scale, 19 * scale, 23 * scale, 15 * scale, paint) + canvas.drawLine(24 * scale, 24 * scale, 28 * scale, 20 * scale, paint) + + return bitmap + } +} diff --git a/apps/mobile-app/app/(tabs)/items/[id].tsx b/apps/mobile-app/app/(tabs)/items/[id].tsx index bbc19b160..fcb94702f 100644 --- a/apps/mobile-app/app/(tabs)/items/[id].tsx +++ b/apps/mobile-app/app/(tabs)/items/[id].tsx @@ -125,7 +125,7 @@ export default function ItemDetailsScreen() : React.ReactNode { - + {item.Name} diff --git a/apps/mobile-app/app/(tabs)/items/deleted.tsx b/apps/mobile-app/app/(tabs)/items/deleted.tsx index 3b0ebc019..6dd086ff4 100644 --- a/apps/mobile-app/app/(tabs)/items/deleted.tsx +++ b/apps/mobile-app/app/(tabs)/items/deleted.tsx @@ -1,10 +1,8 @@ -import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { useFocusEffect } from '@react-navigation/native'; import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, - Image, StyleSheet, Text, TouchableOpacity, @@ -19,6 +17,7 @@ import { useColors } from '@/hooks/useColorScheme'; import { useVaultMutate } from '@/hooks/useVaultMutate'; import { ConfirmDeleteModal } from '@/components/items/ConfirmDeleteModal'; +import { ItemIcon } from '@/components/items/ItemIcon'; import { ThemedContainer } from '@/components/themed/ThemedContainer'; import { ThemedScrollView } from '@/components/themed/ThemedScrollView'; import { ThemedText } from '@/components/themed/ThemedText'; @@ -215,15 +214,6 @@ export default function RecentlyDeletedScreen(): React.ReactNode { marginRight: 12, width: 32, }, - itemLogoPlaceholder: { - alignItems: 'center', - backgroundColor: colors.primary + '20', - borderRadius: 4, - height: 32, - justifyContent: 'center', - marginRight: 12, - width: 32, - }, itemInfo: { flex: 1, }, @@ -284,16 +274,7 @@ export default function RecentlyDeletedScreen(): React.ReactNode { {/* Item logo */} - {item.Logo ? ( - - ) : ( - - - - )} + {/* Item info */} diff --git a/apps/mobile-app/components/items/ItemCard.tsx b/apps/mobile-app/components/items/ItemCard.tsx index 3659cdfd6..98720364a 100644 --- a/apps/mobile-app/components/items/ItemCard.tsx +++ b/apps/mobile-app/components/items/ItemCard.tsx @@ -1,3 +1,4 @@ +/* eslint-disable max-len */ import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { router } from 'expo-router'; import { useTranslation } from 'react-i18next'; @@ -289,7 +290,7 @@ export function ItemCard({ item, onItemDelete }: ItemCardProps): React.ReactNode activeOpacity={0.7} > - + diff --git a/apps/mobile-app/components/items/ItemIcon.tsx b/apps/mobile-app/components/items/ItemIcon.tsx index 3292a4bf3..08b897a4e 100644 --- a/apps/mobile-app/components/items/ItemIcon.tsx +++ b/apps/mobile-app/components/items/ItemIcon.tsx @@ -1,42 +1,290 @@ import { Buffer } from 'buffer'; -import { Image, ImageStyle, StyleSheet } from 'react-native'; +import { Image, ImageStyle, StyleSheet, View } from 'react-native'; +import Svg, { Circle, Path, Rect, Text as SvgText } from 'react-native-svg'; import { SvgUri } from 'react-native-svg'; +import type { Item } from '@/utils/dist/core/models/vault'; +import { ItemTypes, FieldKey } from '@/utils/dist/core/models/vault'; + import servicePlaceholder from '@/assets/images/service-placeholder.webp'; /** - * Item icon props. + * Item icon props - supports both legacy logo-only mode and new item-based mode. */ type ItemIconProps = { + /** Legacy: Logo bytes for Login/Alias items */ logo?: Uint8Array | number[] | string | null; + /** New: Full item object for type-aware icon rendering */ + item?: Item; style?: ImageStyle; }; /** - * Item icon component. + * Credit card brand type */ -export function ItemIcon({ logo, style }: ItemIconProps) : React.ReactNode { +type CardBrand = 'visa' | 'mastercard' | 'amex' | 'discover' | 'generic'; + +/** + * Detect credit card brand from card number using industry-standard prefixes + * @param cardNumber - The card number to detect brand from + * @returns The detected card brand + */ +const detectCardBrand = (cardNumber: string | undefined): CardBrand => { + if (!cardNumber) { + return 'generic'; + } + + // Remove spaces and dashes + const cleaned = cardNumber.replace(/[\s-]/g, ''); + + // Must be mostly numeric + if (!/^\d{4,}/.test(cleaned)) { + return 'generic'; + } + + // Visa: starts with 4 + if (/^4/.test(cleaned)) { + return 'visa'; + } + + // Mastercard: starts with 51-55 or 2221-2720 + if (/^5[1-5]/.test(cleaned) || /^2[2-7]/.test(cleaned)) { + return 'mastercard'; + } + + // Amex: starts with 34 or 37 + if (/^3[47]/.test(cleaned)) { + return 'amex'; + } + + // Discover: starts with 6011, 622, 644-649, 65 + if (/^6(?:011|22|4[4-9]|5)/.test(cleaned)) { + return 'discover'; + } + + return 'generic'; +}; + +/** + * Generic credit card icon in AliasVault style + */ +const CreditCardIcon = ({ width = 32, height = 32 }: { width?: number; height?: number }) => ( + + + + + + +); + +/** + * Visa card icon in AliasVault style + */ +const VisaIcon = ({ width = 32, height = 32 }: { width?: number; height?: number }) => ( + + + + +); + +/** + * Mastercard icon in AliasVault style + */ +const MastercardIcon = ({ width = 32, height = 32 }: { width?: number; height?: number }) => ( + + + + + + +); + +/** + * Amex card icon in AliasVault style + */ +const AmexIcon = ({ width = 32, height = 32 }: { width?: number; height?: number }) => ( + + + + AMEX + + +); + +/** + * Discover card icon in AliasVault style + */ +const DiscoverIcon = ({ width = 32, height = 32 }: { width?: number; height?: number }) => ( + + + + + + + +); + +/** + * Note/document icon in AliasVault style + */ +const NoteIcon = ({ width = 32, height = 32 }: { width?: number; height?: number }) => ( + + + + + + + +); + +/** + * Placeholder icon for Login/Alias items - traditional key design with outline style + */ +const PlaceholderIcon = ({ width = 32, height = 32 }: { width?: number; height?: number }) => ( + + {/* Key bow (circular head) - positioned top-left */} + + {/* Key hole in bow */} + + {/* Key shaft - diagonal */} + + {/* Key teeth - perpendicular to shaft */} + + + +); + +/** + * Get the appropriate icon component based on card brand + */ +const getCardIcon = (brand: CardBrand) => { + switch (brand) { + case 'visa': + return VisaIcon; + case 'mastercard': + return MastercardIcon; + case 'amex': + return AmexIcon; + case 'discover': + return DiscoverIcon; + default: + return CreditCardIcon; + } +}; + +/** + * Item icon component - supports both item-based and legacy logo-based rendering. + */ +export function ItemIcon({ logo, item, style }: ItemIconProps) : React.ReactNode { + const width = Number(style?.width ?? styles.logo.width); + const height = Number(style?.height ?? styles.logo.height); + + // New item-based rendering mode + if (item) { + // For Note type, always show note icon + if (item.ItemType === ItemTypes.Note) { + return ( + + + + ); + } + + // For CreditCard type, detect card brand and show appropriate icon + if (item.ItemType === ItemTypes.CreditCard) { + const cardNumberField = item.Fields?.find(f => f.FieldKey === FieldKey.CardNumber); + const cardNumber = cardNumberField?.Value + ? (Array.isArray(cardNumberField.Value) ? cardNumberField.Value[0] : cardNumberField.Value) + : undefined; + + const brand = detectCardBrand(cardNumber); + const CardIcon = getCardIcon(brand); + + return ( + + + + ); + } + + // For Login/Alias types, use Logo if available, otherwise placeholder + const logoData = item.Logo; + if (logoData && logoData.length > 0) { + return renderLogo(logoData, style); + } + + // Default placeholder for Login/Alias without logo + return ( + + + + ); + } + + // Legacy logo-only rendering mode + if (logo && (typeof logo === 'string' || logo.length > 0)) { + return renderLogo(logo, style); + } + + // Fallback to placeholder + return ( + + + + ); +} + +/** + * Render logo from binary data. + */ +function renderLogo( + logoData: Uint8Array | number[] | string, + style?: ImageStyle +): React.ReactNode { /** * Get the logo source. */ - const getLogoSource = (logoData: Uint8Array | number[] | string | null | undefined) : { type: 'image' | 'svg', source: string | number } => { - if (!logoData) { + const getLogoSource = (data: Uint8Array | number[] | string | null | undefined) : { type: 'image' | 'svg', source: string | number } => { + if (!data) { return { type: 'image', source: servicePlaceholder }; } try { // If logo is already a base64 string (from iOS SQLite query result) - if (typeof logoData === 'string') { - const mimeType = detectMimeTypeFromBase64(logoData); + if (typeof data === 'string') { + const mimeType = detectMimeTypeFromBase64(data); return { type: mimeType === 'image/svg+xml' ? 'svg' : 'image', - source: `data:${mimeType};base64,${logoData}` + source: `data:${mimeType};base64,${data}` }; } // Handle binary data (from Android or other sources) - const logoBytes = toUint8Array(logoData); + const logoBytes = toUint8Array(data); const base64Logo = Buffer.from(logoBytes).toString('base64'); const mimeType = detectMimeType(logoBytes); return { @@ -49,7 +297,7 @@ export function ItemIcon({ logo, style }: ItemIconProps) : React.ReactNode { } }; - const logoSource = getLogoSource(logo); + const logoSource = getLogoSource(logoData); if (logoSource.type === 'svg') { /* @@ -166,4 +414,8 @@ const styles = StyleSheet.create({ height: 32, width: 32, }, + iconContainer: { + borderRadius: 4, + overflow: 'hidden', + }, }); \ No newline at end of file diff --git a/apps/mobile-app/ios/VaultUI/Components/ServiceLogoView.swift b/apps/mobile-app/ios/VaultUI/Components/ItemLogoView.swift similarity index 54% rename from apps/mobile-app/ios/VaultUI/Components/ServiceLogoView.swift rename to apps/mobile-app/ios/VaultUI/Components/ItemLogoView.swift index e8a465af0..9c2315129 100644 --- a/apps/mobile-app/ios/VaultUI/Components/ServiceLogoView.swift +++ b/apps/mobile-app/ios/VaultUI/Components/ItemLogoView.swift @@ -1,14 +1,23 @@ import SwiftUI import Macaw -/// Service logo view -public struct ServiceLogoView: View { +/// Item logo view - displays logos or type-based icons for items +public struct ItemLogoView: View { private let placeholderImageBase64 = "UklGRjoEAABXRUJQVlA4IC4EAAAwFwCdASqAAIAAPpFCm0olo6Ihp5IraLASCWUA0eb/0s56RrLtCnYfLPiBshdXWMx8j1Ez65f169iA4xUDBTEV6ylMQeCIj2b7RngGi7gKZ9WjKdSoy9R8JcgOmjCMlDmLG20KhNo/i/Dc/Ah5GAvGfm8kfniV3AkR6fxN6eKwjDc6xrDgSfS48G5uGV6WzQt24YAVlLSK9BMwndzfHnePK1KFchFrL7O3ulB8cGNCeomu4o+l0SrS/JKblJ4WTzj0DAD++lCUEouSfgRKdiV2TiYCD+H+l3tANKSPQFPQuzi7rbvxqGeRmXB9kDwURaoSTTpYjA9REMUi9uA6aV7PWtBNXgUzMLowYMZeos6Xvyhb34GmufswMHA5ZyYpxzjTphOak4ZjNOiz8aScO5ygiTx99SqwX/uL+HSeVOSraHw8IymrMwm+jLxqN8BS8dGcItLlm/ioulqH2j4V8glDgSut+ExkxiD7m8TGPrrjCQNJbRDzpOFsyCyfBZupvp8QjGKW2KGziSZeIWes4aTB9tRmeEBhnUrmTDZQuXcc67Fg82KHrSfaeeOEq6jjuUjQ8wUnzM4Zz3dhrwSyslVz/WvnKqYkr4V/TTXPFF5EjF4rM1bHZ8bK63EfTnK41+n3n4gEFoYP4mXkNH0hntnYcdTqiE7Gn+q0BpRRxnkpBSZlA6Wa70jpW0FGqkw5e591A5/H+OV+60WAo+4Mi+NlsKrvLZ9EiVaPnoEFZlJQx1fA777AJ2MjXJ4KSsrWDWJi1lE8yPs8V6XvcC0chDTYt8456sKXAagCZyY+fzQriFMaddXyKQdG8qBqcdYjAsiIcjzaRFBBoOK9sU+sFY7N6B6+xtrlu3c37rQKkI3O2EoiJOris54EjJ5OFuumA0M6riNUuBf/MEPFBVx1JRcUEs+upEBsCnwYski7FT3TTqHrx7v5AjgFN97xhPTkmVpu6sxRnWBi1fxIRp8eWZeFM6mUcGgVk1WeVb1yhdV9hoMo2TsNEPE0tHo/wvuSJSzbZo7wibeXM9v/rRfKcx7X93rfiXVnyQ9f/5CaAQ4lxedPp/6uzLtOS4FyL0bCNeZ6L5w+AiuyWCTDFIYaUzhwfG+/YTQpWyeZCdQIKzhV+3GeXI2cxoP0ER/DlOKymf1gm+zRU3sqf1lBVQ0y+mK/Awl9bS3uaaQmI0FUyUwHUKP7PKuXnO+LcwDv4OfPT6hph8smc1EtMe5ib/apar/qZ9dyaEaElALJ1KKxnHziuvVl8atk1fINSQh7OtXDyqbPw9o/nGIpTnv5iFmwmWJLis2oyEgPkJqyx0vYI8rjkVEzKc8eQavAJBYSpjMwM193Swt+yJyjvaGYWPnqExxKiNarpB2WSO7soCAZXhS1uEYHryrK47BH6W1dRiruqT0xpLih3MXiwU3VDwAAAA==" // swiftlint:disable:this line_length let logoData: Data? + let itemType: String? + let cardNumber: String? + @Environment(\.colorScheme) private var colorScheme + public init(logoData: Data?, itemType: String? = nil, cardNumber: String? = nil) { + self.logoData = logoData + self.itemType = itemType + self.cardNumber = cardNumber + } + private var colors: ColorConstants.Colors.Type { ColorConstants.colors(for: colorScheme) } @@ -66,32 +75,91 @@ public struct ServiceLogoView: View { public var body: some View { Group { - if let logoData = logoData { - let mimeType = detectMimeType(logoData) - if mimeType == "image/svg+xml", - let svgNode = renderSVGNode(logoData) { - SVGImageView(node: svgNode) - .frame(width: 32, height: 32) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } else if let image = UIImage(data: logoData) { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 32, height: 32) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } else if let placeholder = placeholderImage { - Image(uiImage: placeholder) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 32, height: 32) - .clipShape(RoundedRectangle(cornerRadius: 4)) - } + // If itemType is specified, use type-based rendering + if let itemType = itemType { + renderTypeBasedIcon(itemType: itemType) + } else if let logoData = logoData { + // Legacy logo rendering + renderLogo(logoData: logoData) + } else { + // Fallback to placeholder + renderPlaceholder() + } + } + } + + /// Render icon based on item type + private func renderTypeBasedIcon(itemType: String) -> some View { + Group { + // For Note type, always show note icon + if itemType == ItemTypeIcon.ItemType.note.rawValue { + renderSVGIcon(svg: ItemTypeIcon.noteIcon) + } + // For CreditCard type, detect brand and show appropriate icon + else if itemType == ItemTypeIcon.ItemType.creditCard.rawValue { + let brand = ItemTypeIcon.CardBrand.detect(from: cardNumber) + let cardIcon = ItemTypeIcon.getCardIcon(for: brand) + renderSVGIcon(svg: cardIcon) + } + // For Login/Alias types, use Logo if available, otherwise placeholder + else if let logoData = logoData, !logoData.isEmpty { + renderLogo(logoData: logoData) + } else { + renderSVGIcon(svg: ItemTypeIcon.placeholderIcon) + } + } + } + + /// Render an SVG icon from string + private func renderSVGIcon(svg: String) -> some View { + Group { + if let svgData = svg.data(using: .utf8), + let svgNode = try? SVGParser.parse(text: svg) { + SVGImageView(node: svgNode) + .frame(width: 32, height: 32) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + renderPlaceholder() + } + } + } + + /// Render logo from binary data + private func renderLogo(logoData: Data) -> some View { + Group { + let mimeType = detectMimeType(logoData) + if mimeType == "image/svg+xml", + let svgNode = renderSVGNode(logoData) { + SVGImageView(node: svgNode) + .frame(width: 32, height: 32) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else if let image = UIImage(data: logoData) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 32, height: 32) + .clipShape(RoundedRectangle(cornerRadius: 4)) } else if let placeholder = placeholderImage { Image(uiImage: placeholder) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 32, height: 32) .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + renderPlaceholder() + } + } + } + + /// Render fallback placeholder + private func renderPlaceholder() -> some View { + Group { + if let placeholder = placeholderImage { + Image(uiImage: placeholder) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 32, height: 32) + .clipShape(RoundedRectangle(cornerRadius: 4)) } else { // Ultimate fallback if placeholder fails to load Circle() @@ -108,5 +176,5 @@ public struct ServiceLogoView: View { } #Preview { - ServiceLogoView(logoData: nil) + ItemLogoView(logoData: nil) } diff --git a/apps/mobile-app/ios/VaultUI/Components/ItemTypeIcon.swift b/apps/mobile-app/ios/VaultUI/Components/ItemTypeIcon.swift new file mode 100644 index 000000000..6e6bb5ea5 --- /dev/null +++ b/apps/mobile-app/ios/VaultUI/Components/ItemTypeIcon.swift @@ -0,0 +1,159 @@ +// swiftlint:disable line_length +import SwiftUI + +/// Item type icon helper - provides SVG-based icons for different item types +public struct ItemTypeIcon { + + /// Item type enumeration matching the database model + public enum ItemType: String { + case login = "Login" + case alias = "Alias" + case creditCard = "CreditCard" + case note = "Note" + } + + /// Credit card brand type + public enum CardBrand { + case visa + case mastercard + case amex + case discover + case generic + + /// Detect credit card brand from card number using industry-standard prefixes + public static func detect(from cardNumber: String?) -> CardBrand { + guard let cardNumber = cardNumber else { + return .generic + } + + // Remove spaces and dashes + let cleaned = cardNumber.replacingOccurrences(of: "[\\s-]", with: "", options: .regularExpression) + + // Must be mostly numeric + guard cleaned.range(of: "^\\d{4,}", options: .regularExpression) != nil else { + return .generic + } + + // Visa: starts with 4 + if cleaned.hasPrefix("4") { + return .visa + } + + // Mastercard: starts with 51-55 or 2221-2720 + if cleaned.range(of: "^5[1-5]", options: .regularExpression) != nil || + cleaned.range(of: "^2[2-7]", options: .regularExpression) != nil { + return .mastercard + } + + // Amex: starts with 34 or 37 + if cleaned.range(of: "^3[47]", options: .regularExpression) != nil { + return .amex + } + + // Discover: starts with 6011, 622, 644-649, 65 + if cleaned.range(of: "^6(?:011|22|4[4-9]|5)", options: .regularExpression) != nil { + return .discover + } + + return .generic + } + } + + /// Generic credit card icon SVG + public static let creditCardIcon = """ + + + + + + + """ + + /// Visa card icon SVG + public static let visaIcon = """ + + + + + """ + + /// Mastercard icon SVG + public static let mastercardIcon = """ + + + + + + + """ + + /// Amex card icon SVG + public static let amexIcon = """ + + + AMEX + + """ + + /// Discover card icon SVG + public static let discoverIcon = """ + + + + + + + + """ + + /// Note/document icon SVG + public static let noteIcon = """ + + + + + + + + """ + + /// Placeholder key icon SVG for Login/Alias without logo + public static let placeholderIcon = """ + + + + + + + + """ + + /// Get the appropriate SVG icon for a credit card brand + public static func getCardIcon(for brand: CardBrand) -> String { + switch brand { + case .visa: + return visaIcon + case .mastercard: + return mastercardIcon + case .amex: + return amexIcon + case .discover: + return discoverIcon + case .generic: + return creditCardIcon + } + } + + /// Get the appropriate SVG icon for an item type + public static func getIcon(for itemType: ItemType, cardNumber: String? = nil) -> String { + switch itemType { + case .note: + return noteIcon + case .creditCard: + let brand = CardBrand.detect(from: cardNumber) + return getCardIcon(for: brand) + case .login, .alias: + return placeholderIcon + } + } +} diff --git a/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift b/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift index cc1568406..f798d4451 100644 --- a/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift +++ b/apps/mobile-app/ios/VaultUI/Selection/Components/CredentialCardView.swift @@ -25,7 +25,7 @@ public struct AutofillCredentialCard: View { Button(action: action) { HStack(spacing: 16) { // Service logo - ServiceLogoView(logoData: credential.logo) + ItemLogoView(logoData: credential.logo) .frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 4) {