diff --git a/auth/lib/models/all_icon_data.dart b/auth/lib/models/all_icon_data.dart new file mode 100644 index 0000000000..732667a262 --- /dev/null +++ b/auth/lib/models/all_icon_data.dart @@ -0,0 +1,15 @@ +enum IconType { simpleIcon, customIcon } + +class AllIconData { + final String title; + final IconType type; + final String? color; + final String? slug; + + AllIconData({ + required this.title, + required this.type, + required this.color, + this.slug, + }); +} diff --git a/auth/lib/models/code_display.dart b/auth/lib/models/code_display.dart index 71b74c68f5..6b3d6bb1df 100644 --- a/auth/lib/models/code_display.dart +++ b/auth/lib/models/code_display.dart @@ -12,6 +12,8 @@ class CodeDisplay { String note; final List tags; int position; + String iconSrc; + String iconID; CodeDisplay({ this.pinned = false, @@ -21,8 +23,12 @@ class CodeDisplay { this.tags = const [], this.note = '', this.position = 0, + this.iconSrc = '', + this.iconID = '', }); + bool get isCustomIcon => (iconSrc != '' && iconID != ''); + // copyWith CodeDisplay copyWith({ bool? pinned, @@ -32,6 +38,8 @@ class CodeDisplay { List? tags, String? note, int? position, + String? iconSrc, + String? iconID, }) { final bool updatedPinned = pinned ?? this.pinned; final bool updatedTrashed = trashed ?? this.trashed; @@ -40,6 +48,8 @@ class CodeDisplay { final List updatedTags = tags ?? this.tags; final String updatedNote = note ?? this.note; final int updatedPosition = position ?? this.position; + final String updatedIconSrc = iconSrc ?? this.iconSrc; + final String updatedIconID = iconID ?? this.iconID; return CodeDisplay( pinned: updatedPinned, @@ -49,6 +59,8 @@ class CodeDisplay { tags: updatedTags, note: updatedNote, position: updatedPosition, + iconSrc: updatedIconSrc, + iconID: updatedIconID, ); } @@ -64,6 +76,8 @@ class CodeDisplay { tags: List.from(json['tags'] ?? []), note: json['note'] ?? '', position: json['position'] ?? 0, + iconSrc: json['iconSrc'] ?? 'ente', + iconID: json['iconID'] ?? '', ); } @@ -106,6 +120,8 @@ class CodeDisplay { 'tags': tags, 'note': note, 'position': position, + 'iconSrc': iconSrc, + 'iconID': iconID, }; } diff --git a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart index 0491f48bdc..4857221638 100644 --- a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart +++ b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:ente_auth/core/event_bus.dart'; import 'package:ente_auth/events/codes_updated_event.dart'; import "package:ente_auth/l10n/l10n.dart"; +import 'package:ente_auth/models/all_icon_data.dart'; import 'package:ente_auth/models/code.dart'; import 'package:ente_auth/models/code_display.dart'; import 'package:ente_auth/onboarding/model/tag_enums.dart'; @@ -13,7 +14,10 @@ import 'package:ente_auth/onboarding/view/common/tag_chip.dart'; import 'package:ente_auth/store/code_display_store.dart'; import 'package:ente_auth/theme/ente_theme.dart'; import 'package:ente_auth/ui/components/buttons/button_widget.dart'; +import 'package:ente_auth/ui/components/custom_icon_widget.dart'; import 'package:ente_auth/ui/components/models/button_result.dart'; +import 'package:ente_auth/ui/custom_icon_page.dart'; +import 'package:ente_auth/ui/utils/icon_utils.dart'; import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/toast_util.dart'; import 'package:ente_auth/utils/totp_util.dart'; @@ -42,6 +46,9 @@ class _SetupEnterSecretKeyPageState extends State { late List selectedTags = [...?widget.code?.display.tags]; List allTags = []; StreamSubscription? _streamSubscription; + bool isCustomIcon = false; + String _customIconID = ""; + late IconType _iconSrc; @override void initState() { @@ -81,6 +88,19 @@ class _SetupEnterSecretKeyPageState extends State { _limitTextLength(_accountController, _otherTextLimit); _limitTextLength(_secretController, _otherTextLimit); } + + isCustomIcon = widget.code?.display.isCustomIcon ?? false; + if (isCustomIcon) { + _customIconID = widget.code?.display.iconID ?? "ente"; + } else { + if (widget.code != null) { + _customIconID = widget.code!.issuer; + } + } + _iconSrc = widget.code?.display.iconSrc == "simpleIcon" + ? IconType.simpleIcon + : IconType.customIcon; + super.initState(); } @@ -280,9 +300,21 @@ class _SetupEnterSecretKeyPageState extends State { ), ], ), - const SizedBox( - height: 40, - ), + const SizedBox(height: 32), + if (widget.code != null) + CustomIconWidget(iconData: _customIconID), + const SizedBox(height: 24), + if (widget.code != null) + GestureDetector( + onTap: () async { + await navigateToCustomIconPage(); + }, + child: Text( + "Change Icon", + style: getEnteTextTheme(context).small, + ), + ), + const SizedBox(height: 40), SizedBox( width: 400, child: OutlinedButton( @@ -324,6 +356,11 @@ class _SetupEnterSecretKeyPageState extends State { widget.code?.display.copyWith(tags: selectedTags) ?? CodeDisplay(tags: selectedTags); display.note = notes; + + display.iconID = _customIconID.toLowerCase(); + display.iconSrc = + _iconSrc == IconType.simpleIcon ? 'simpleIcon' : 'customIcon'; + if (widget.code != null && widget.code!.secret != secret) { ButtonResult? result = await showChoiceActionSheet( context, @@ -373,4 +410,28 @@ class _SetupEnterSecretKeyPageState extends State { message ?? context.l10n.pleaseVerifyDetails, ); } + + Future navigateToCustomIconPage() async { + final allIcons = IconUtils.instance.getAllIcons(); + String currentIcon; + if (widget.code!.display.isCustomIcon) { + currentIcon = widget.code!.display.iconID; + } else { + currentIcon = widget.code!.issuer; + } + final AllIconData newCustomIcon = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return CustomIconPage( + currentIcon: currentIcon, + allIcons: allIcons, + ); + }, + ), + ); + setState(() { + _customIconID = newCustomIcon.title; + _iconSrc = newCustomIcon.type; + }); + } } diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index 16c82c848b..c04feddfb0 100644 --- a/auth/lib/ui/code_widget.dart +++ b/auth/lib/ui/code_widget.dart @@ -445,13 +445,19 @@ class _CodeWidgetState extends State { } Widget _getIcon() { + final String iconData; + if (widget.code.display.isCustomIcon) { + iconData = widget.code.display.iconID; + } else { + iconData = widget.code.issuer; + } return Padding( padding: _shouldShowLargeIcon ? EdgeInsets.only(left: widget.isCompactMode ? 12 : 16) : const EdgeInsets.all(0), child: IconUtils.instance.getIcon( context, - safeDecode(widget.code.issuer).trim(), + safeDecode(iconData).trim(), width: widget.isCompactMode ? (_shouldShowLargeIcon ? 32 : 24) : (_shouldShowLargeIcon ? 42 : 24), diff --git a/auth/lib/ui/components/custom_icon_widget.dart b/auth/lib/ui/components/custom_icon_widget.dart new file mode 100644 index 0000000000..4825ddff60 --- /dev/null +++ b/auth/lib/ui/components/custom_icon_widget.dart @@ -0,0 +1,37 @@ +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/utils/icon_utils.dart'; +import 'package:ente_auth/utils/totp_util.dart'; +import 'package:flutter/material.dart'; + +class CustomIconWidget extends StatelessWidget { + final String iconData; + + CustomIconWidget({ + super.key, + required this.iconData, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 70, + height: 70, + decoration: BoxDecoration( + border: Border.all( + width: 1.5, + color: getEnteColorScheme(context).tagChipSelectedColor, + ), + borderRadius: const BorderRadius.all(Radius.circular(12.0)), + ), + padding: const EdgeInsets.all(8), + child: FittedBox( + fit: BoxFit.contain, + child: IconUtils.instance.getIcon( + context, + safeDecode(iconData).trim(), + width: 50, + ), + ), + ); + } +} diff --git a/auth/lib/ui/custom_icon_page.dart b/auth/lib/ui/custom_icon_page.dart new file mode 100644 index 0000000000..01edbea9be --- /dev/null +++ b/auth/lib/ui/custom_icon_page.dart @@ -0,0 +1,216 @@ +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/all_icon_data.dart'; +import 'package:ente_auth/services/preference_service.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/utils/icon_utils.dart'; +import 'package:flutter/material.dart'; + +class CustomIconPage extends StatefulWidget { + final Map allIcons; + final String currentIcon; + + const CustomIconPage({ + super.key, + required this.allIcons, + required this.currentIcon, + }); + + @override + State createState() => _CustomIconPageState(); +} + +class _CustomIconPageState extends State { + Map _filteredIcons = {}; + bool _showSearchBox = false; + final bool _autoFocusSearch = + PreferenceService.instance.shouldAutoFocusOnSearchBar(); + final TextEditingController _textController = TextEditingController(); + String _searchText = ""; + + // Used to request focus on the search box when clicked the search icon + late FocusNode searchBoxFocusNode; + + @override + void initState() { + _filteredIcons = widget.allIcons; + _showSearchBox = _autoFocusSearch; + searchBoxFocusNode = FocusNode(); + super.initState(); + } + + @override + void dispose() { + _textController.dispose(); + searchBoxFocusNode.dispose(); + super.dispose(); + } + + void _applyFilteringAndRefresh() { + if (_searchText.isEmpty) { + setState(() { + _filteredIcons = widget.allIcons; + }); + return; + } + + final filteredIcons = {}; + widget.allIcons.forEach((title, iconData) { + if (title.toLowerCase().contains(_searchText.toLowerCase())) { + filteredIcons[title] = iconData; + } + }); + + setState(() { + _filteredIcons = filteredIcons; + }); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar( + title: !_showSearchBox + ? const Text('Custom Branding') + : TextField( + autocorrect: false, + enableSuggestions: false, + autofocus: _autoFocusSearch, + controller: _textController, + onChanged: (value) { + _searchText = value; + _applyFilteringAndRefresh(); + }, + decoration: InputDecoration( + hintText: l10n.searchHint, + border: InputBorder.none, + focusedBorder: InputBorder.none, + ), + focusNode: searchBoxFocusNode, + ), + actions: [ + IconButton( + icon: _showSearchBox + ? const Icon(Icons.clear) + : const Icon(Icons.search), + tooltip: "Search", + onPressed: () { + setState( + () { + _showSearchBox = !_showSearchBox; + if (!_showSearchBox) { + _textController.clear(); + _searchText = ""; + } else { + _searchText = _textController.text; + + // Request focus on the search box + searchBoxFocusNode.requestFocus(); + } + _applyFilteringAndRefresh(); + }, + ); + }, + ), + ], + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Expanded( + child: GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: (MediaQuery.sizeOf(context).width ~/ 90) + .clamp(1, double.infinity) + .toInt(), + crossAxisSpacing: 14, + mainAxisSpacing: 14, + childAspectRatio: 1, + ), + itemCount: _filteredIcons.length, + itemBuilder: (context, index) { + final title = _filteredIcons.keys.elementAt(index); + final iconData = _filteredIcons[title]!; + IconType iconType = iconData.type; + String? color = iconData.color; + String? slug = iconData.slug; + + Widget iconWidget; + if (iconType == IconType.simpleIcon) { + iconWidget = IconUtils.instance.getSVGIcon( + "assets/simple-icons/icons/$title.svg", + title, + color, + 40, + context, + ); + } else { + iconWidget = IconUtils.instance.getSVGIcon( + "assets/custom-icons/icons/${slug ?? title}.svg", + title, + color, + 40, + context, + ); + } + + return GestureDetector( + onTap: () { + final newIcon = AllIconData( + title: title, + type: iconType, + color: color, + slug: slug, + ); + Navigator.of(context).pop(newIcon); + }, + child: Container( + decoration: BoxDecoration( + border: Border.all( + width: 1.5, + color: title.toLowerCase() == + widget.currentIcon.toLowerCase() + ? getEnteColorScheme(context) + .tagChipSelectedColor + : Colors.transparent, + ), + borderRadius: const BorderRadius.all( + Radius.circular(12.0), + ), + ), + child: Column( + children: [ + const SizedBox(height: 8), + Expanded( + child: iconWidget, + ), + const SizedBox(height: 12), + Padding( + padding: title.toLowerCase() == + widget.currentIcon.toLowerCase() + ? const EdgeInsets.only(left: 2, right: 2) + : const EdgeInsets.all(0.0), + child: Text( + '${title[0].toUpperCase()}${title.substring(1)}', + style: getEnteTextTheme(context).mini, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + const SizedBox(height: 4), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/auth/lib/ui/utils/icon_utils.dart b/auth/lib/ui/utils/icon_utils.dart index cc05787401..76896a7b36 100644 --- a/auth/lib/ui/utils/icon_utils.dart +++ b/auth/lib/ui/utils/icon_utils.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/models/all_icon_data.dart'; import 'package:ente_auth/theme/ente_theme.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -24,6 +25,80 @@ class IconUtils { await _loadJson(); } + Map getAllIcons() { + Set processedIconPaths = {}; + final allIcons = {}; + + final simpleIterator = _simpleIcons.entries.iterator; + final customIterator = _customIcons.entries.iterator; + + var simpleEntry = simpleIterator.moveNext() ? simpleIterator.current : null; + var customEntry = customIterator.moveNext() ? customIterator.current : null; + + String simpleIconPath, customIconPath; + + while (simpleEntry != null && customEntry != null) { + if (simpleEntry.key.compareTo(customEntry.key) <= 0) { + simpleIconPath = "assets/simple-icons/icons/${simpleEntry.key}.svg"; + if (!processedIconPaths.contains(simpleIconPath)) { + allIcons[simpleEntry.key] = AllIconData( + title: simpleEntry.key, + type: IconType.simpleIcon, + color: simpleEntry.value, + ); + processedIconPaths.add(simpleIconPath); + } + simpleEntry = simpleIterator.moveNext() ? simpleIterator.current : null; + } else { + customIconPath = + "assets/custom-icons/icons/${customEntry.value.slug ?? customEntry.key}.svg"; + + if (!processedIconPaths.contains(customIconPath)) { + allIcons[customEntry.key] = AllIconData( + title: customEntry.key, + type: IconType.customIcon, + color: customEntry.value.color, + slug: customEntry.value.slug, + ); + processedIconPaths.add(customIconPath); + } + customEntry = customIterator.moveNext() ? customIterator.current : null; + } + } + + while (simpleEntry != null) { + simpleIconPath = "assets/simple-icons/icons/${simpleEntry.key}.svg"; + + if (!processedIconPaths.contains(simpleIconPath)) { + allIcons[simpleEntry.key] = AllIconData( + title: simpleEntry.key, + type: IconType.simpleIcon, + color: simpleEntry.value, + ); + processedIconPaths.add(simpleIconPath); + } + simpleEntry = simpleIterator.moveNext() ? simpleIterator.current : null; + } + + while (customEntry != null) { + customIconPath = + "assets/custom-icons/icons/${customEntry.value.slug ?? customEntry.key}.svg"; + + if (!processedIconPaths.contains(customIconPath)) { + allIcons[customEntry.key] = AllIconData( + title: customEntry.key, + type: IconType.customIcon, + color: customEntry.value.color, + slug: customEntry.value.slug, + ); + processedIconPaths.add(customIconPath); + } + customEntry = customIterator.moveNext() ? customIterator.current : null; + } + + return allIcons; + } + Widget getIcon( BuildContext context, String provider, { @@ -38,7 +113,7 @@ class IconUtils { ); for (final title in titlesList) { if (_customIcons.containsKey(title)) { - return _getSVGIcon( + return getSVGIcon( "assets/custom-icons/icons/${_customIcons[title]!.slug ?? title}.svg", title, _customIcons[title]!.color, @@ -46,7 +121,7 @@ class IconUtils { context, ); } else if (_simpleIcons.containsKey(title)) { - return _getSVGIcon( + return getSVGIcon( "assets/simple-icons/icons/$title.svg", title, _simpleIcons[title], @@ -75,7 +150,7 @@ class IconUtils { } } - Widget _getSVGIcon( + Widget getSVGIcon( String path, String title, String? color,