mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-11-25 14:04:55 +03:00
Merge PR #968.
This commit is contained in:
commit
64e2d1bc51
22
doc/Custom_Account_Icons.adoc
Normal file
22
doc/Custom_Account_Icons.adoc
Normal file
@ -0,0 +1,22 @@
|
||||
== Custom Account Icons
|
||||
Yubico Authenticator supports adding a custom icon pack to use for your OATH
|
||||
(TOTP/HOTP) accounts, making the list a bit easier to navigate. Icon packs must
|
||||
be in the
|
||||
https://github.com/beemdevelopment/Aegis/blob/master/docs/iconpacks.md[Aegis Icon Pack format].
|
||||
You can use one of the existing pre-built packages from
|
||||
https://aegis-icons.github.io/ or
|
||||
https://github.com/alexbakker/aegis-simple-icons, or you can create your own.
|
||||
|
||||
=== Installing an icon pack
|
||||
Once you have an icon pack on your computer or Android device and wish to use
|
||||
it, follow these steps:
|
||||
|
||||
1. Open the Yubico Authenticator app and insert or tap your OATH-enabled YubiKey.
|
||||
2. Make sure the `Authenticator` section is chosen, where you can see your list of accounts.
|
||||
3. Press the `Configure YubiKey` button, and select `Custom icons` from the menu.
|
||||
4. Press the `Load icon pack` button, select the icon pack zip-file, and wait for it to load.
|
||||
|
||||
Once done, any account that has an issuer that is supported by the icon pack
|
||||
will display the custom icon instead of the standard colored circle with a
|
||||
letter. You can also use the `Custom icons` dialog to replace the icon pack, or
|
||||
to remove it.
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Yubico.
|
||||
* Copyright (C) 2022-2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@ -163,8 +163,7 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier {
|
||||
_sub = _events.receiveBroadcastStream().listen((event) {
|
||||
final json = jsonDecode(event);
|
||||
List<OathPair>? newState = json != null
|
||||
? List.unmodifiable(
|
||||
(json as List).map((e) => OathPair.fromJson(e)).toList())
|
||||
? List.from((json as List).map((e) => OathPair.fromJson(e)).toList())
|
||||
: null;
|
||||
if (state != null && newState == null) {
|
||||
// If we go from non-null to null this means we should stop listening to
|
||||
|
@ -59,6 +59,7 @@ class MainPage extends ConsumerWidget {
|
||||
'licenses',
|
||||
'user_interaction_prompt',
|
||||
'oath_add_account',
|
||||
'oath_icon_pack_dialog',
|
||||
].contains(route.settings.name);
|
||||
});
|
||||
});
|
||||
|
@ -83,12 +83,34 @@
|
||||
"capacity": {}
|
||||
}
|
||||
},
|
||||
"oath_custom_icons": "Custom icons",
|
||||
"oath_custom_icons_description": "Icon packs can make your accounts more easily distinguishable with familiar logos and colors.",
|
||||
"oath_custom_icons_replace": "Replace icon pack",
|
||||
"oath_custom_icons_loading": "Loading icon pack\u2026",
|
||||
"oath_custom_icons_load": "Load icon pack",
|
||||
"oath_custom_icons_remove": "Remove icon pack",
|
||||
"oath_custom_icons_icon_pack_removed": "Icon pack removed",
|
||||
"oath_custom_icons_err_icon_pack_remove": "Error removing icon pack",
|
||||
"oath_custom_icons_choose_icon_pack": "Choose icon pack",
|
||||
"oath_custom_icons_icon_pack_imported": "Icon pack imported",
|
||||
"oath_custom_icons_err_icon_pack_import": "Error importing icon pack: {message}",
|
||||
"@oath_custom_icons_err_icon_pack_import": {
|
||||
"placeholders": {
|
||||
"message": {}
|
||||
}
|
||||
},
|
||||
"oath_custom_icons_learn_more": "Learn\u00a0more",
|
||||
"oath_custom_icons_err_import_general": "Import error",
|
||||
"oath_custom_icons_err_file_not_found": "File not found",
|
||||
"oath_custom_icons_err_file_too_big": "File size too big",
|
||||
"oath_custom_icons_err_invalid_icon_pack": "Invalid icon pack",
|
||||
"oath_custom_icons_err_filesystem_error": "File system operation error",
|
||||
|
||||
"widgets_cancel": "Cancel",
|
||||
"widgets_close": "Close",
|
||||
|
||||
"mgmt_min_one_interface": "At least one interface must be enabled",
|
||||
"mgmt_reconfiguring_yubikey": "Reconfiguring YubiKey...",
|
||||
"mgmt_reconfiguring_yubikey": "Reconfiguring YubiKey\u2026",
|
||||
"mgmt_configuration_updated": "Configuration updated",
|
||||
"mgmt_configuration_updated_remove_reinsert": "Configuration updated, remove and reinsert your YubiKey",
|
||||
"mgmt_toggle_applications": "Toggle applications",
|
||||
@ -125,7 +147,7 @@
|
||||
"general_quit": "Quit",
|
||||
|
||||
"fido_press_fingerprint_begin": "Press your finger against the YubiKey to begin.",
|
||||
"fido_keep_touching_yubikey": "Keep touching your YubiKey repeatedly...",
|
||||
"fido_keep_touching_yubikey": "Keep touching your YubiKey repeatedly\u2026",
|
||||
"fido_fingerprint_captured": "Fingerprint captured successfully!",
|
||||
"fido_fingerprint_added": "Fingerprint added",
|
||||
"fido_error_setting_name": "Error setting name",
|
||||
@ -212,7 +234,7 @@
|
||||
"fido_place_back_on_reader": "Place your YubiKey back on the reader",
|
||||
"fido_reinsert_yubikey": "Re-insert your YubiKey",
|
||||
"fido_touch_yubikey": "Touch your YubiKey now",
|
||||
"fido_press_reset": "Press reset to begin...",
|
||||
"fido_press_reset": "Press reset to begin\u2026",
|
||||
"fido_factory_reset": "Factory reset",
|
||||
"fido_factory_reset_description": "Factory reset this application",
|
||||
"fido_fido_app_reset": "FIDO application reset",
|
||||
@ -240,7 +262,7 @@
|
||||
|
||||
"appFailurePage_btn_unlock": "Unlock",
|
||||
"appFailurePage_txt_info": "WebAuthn management requires elevated privileges.",
|
||||
"appFailurePage_msg_permission": "Elevating permissions...",
|
||||
"appFailurePage_msg_permission": "Elevating permissions\u2026",
|
||||
|
||||
"mainDrawer_txt_applications": "Toggle applications",
|
||||
"mainDrawer_txt_settings": "Settings",
|
||||
|
101
lib/oath/icon_provider/icon_cache.dart
Normal file
101
lib/oath/icon_provider/icon_cache.dart
Normal file
@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:yubico_authenticator/app/logging.dart';
|
||||
|
||||
final _log = Logger('icon_cache');
|
||||
|
||||
class IconCacheFs {
|
||||
Future<ByteData?> read(String fileName) async {
|
||||
final file = await _getFile(fileName);
|
||||
final exists = await file.exists();
|
||||
if (exists) {
|
||||
_log.debug('File $fileName exists in cache');
|
||||
} else {
|
||||
_log.debug('File $fileName does not exist in cache');
|
||||
}
|
||||
return exists ? (await file.readAsBytes()).buffer.asByteData() : null;
|
||||
}
|
||||
|
||||
Future<void> write(String fileName, Uint8List data) async {
|
||||
_log.debug('Writing $fileName to cache');
|
||||
final file = await _getFile(fileName);
|
||||
if (!await file.exists()) {
|
||||
await file.create(recursive: true, exclusive: false);
|
||||
}
|
||||
await file.writeAsBytes(data, flush: true);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final cacheDirectory = await _cacheDirectory;
|
||||
if (await cacheDirectory.exists()) {
|
||||
try {
|
||||
await cacheDirectory.delete(recursive: true);
|
||||
} catch (e) {
|
||||
_log.error(
|
||||
'Failed to delete cache directory ${cacheDirectory.path}', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _buildCacheDirectoryPath(String supportDirectory) =>
|
||||
join(supportDirectory, 'issuer_icons_cache');
|
||||
|
||||
Future<Directory> get _cacheDirectory async {
|
||||
final supportDirectory = await getApplicationSupportDirectory();
|
||||
return Directory(_buildCacheDirectoryPath(supportDirectory.path));
|
||||
}
|
||||
|
||||
Future<File> _getFile(String fileName) async {
|
||||
final supportDirectory = await getApplicationSupportDirectory();
|
||||
final cacheDirectoryPath = _buildCacheDirectoryPath(supportDirectory.path);
|
||||
return File(
|
||||
join(cacheDirectoryPath, '${basenameWithoutExtension(fileName)}.dat'));
|
||||
}
|
||||
}
|
||||
|
||||
class IconCacheMem {
|
||||
final _cache = <String, ByteData>{};
|
||||
|
||||
ByteData? read(String fileName) {
|
||||
return _cache[fileName];
|
||||
}
|
||||
|
||||
void write(String fileName, Uint8List data) {
|
||||
_cache.putIfAbsent(fileName, () => data.buffer.asByteData());
|
||||
}
|
||||
|
||||
void clear() async {
|
||||
_cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class IconCache {
|
||||
final IconCacheMem memCache;
|
||||
final IconCacheFs fsCache;
|
||||
|
||||
const IconCache(this.memCache, this.fsCache);
|
||||
}
|
||||
|
||||
final iconCacheProvider =
|
||||
Provider<IconCache>((ref) => IconCache(IconCacheMem(), IconCacheFs()));
|
81
lib/oath/icon_provider/icon_file_loader.dart
Normal file
81
lib/oath/icon_provider/icon_file_loader.dart
Normal file
@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:vector_graphics/vector_graphics.dart';
|
||||
import 'package:vector_graphics_compiler/vector_graphics_compiler.dart';
|
||||
import 'package:yubico_authenticator/app/logging.dart';
|
||||
|
||||
import 'icon_cache.dart';
|
||||
|
||||
final _log = Logger('icon_file_loader');
|
||||
|
||||
class IconFileLoader extends BytesLoader {
|
||||
final WidgetRef _ref;
|
||||
final File _file;
|
||||
|
||||
const IconFileLoader(this._ref, this._file);
|
||||
|
||||
@override
|
||||
Future<ByteData> loadBytes(BuildContext? context) async {
|
||||
final cacheFileName = basename(_file.path);
|
||||
|
||||
final memCache = _ref.read(iconCacheProvider).memCache;
|
||||
|
||||
// check if the requested file exists in memory cache
|
||||
var cachedData = memCache.read(cacheFileName);
|
||||
if (cachedData != null) {
|
||||
_log.debug('Returning $cacheFileName image data from memory cache');
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
final fsCache = _ref.read(iconCacheProvider).fsCache;
|
||||
|
||||
// check if the requested file exists in fs cache
|
||||
cachedData = await fsCache.read(cacheFileName);
|
||||
if (cachedData != null) {
|
||||
memCache.write(cacheFileName, cachedData.buffer.asUint8List());
|
||||
_log.debug('Returning $cacheFileName image data from fs cache');
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
final decodedData = await compute((File file) async {
|
||||
final fileData = await file.readAsString();
|
||||
final TimelineTask task = TimelineTask()..start('encodeSvg');
|
||||
final Uint8List compiledBytes = encodeSvg(
|
||||
xml: fileData,
|
||||
debugName: file.path,
|
||||
enableClippingOptimizer: false,
|
||||
enableMaskingOptimizer: false,
|
||||
enableOverdrawOptimizer: false,
|
||||
);
|
||||
task.finish();
|
||||
// for testing try: await Future.delayed(const Duration(seconds: 5));
|
||||
return compiledBytes;
|
||||
}, _file, debugLabel: 'Process SVG data');
|
||||
|
||||
memCache.write(cacheFileName, decodedData);
|
||||
await fsCache.write(cacheFileName, decodedData);
|
||||
return decodedData.buffer.asByteData();
|
||||
}
|
||||
}
|
73
lib/oath/icon_provider/icon_pack.dart
Normal file
73
lib/oath/icon_provider/icon_pack.dart
Normal file
@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
String getLocalIconFileName(String iconPackFileName) {
|
||||
final sha = sha256.convert(utf8.encode(iconPackFileName)).toString();
|
||||
return sha.substring(0, sha.length ~/ 2) + extension(iconPackFileName);
|
||||
}
|
||||
|
||||
class IconPackIcon {
|
||||
final String filename;
|
||||
final String? category;
|
||||
final List<String> issuer;
|
||||
|
||||
const IconPackIcon({
|
||||
required this.filename,
|
||||
required this.category,
|
||||
required this.issuer,
|
||||
});
|
||||
}
|
||||
|
||||
class IconPack {
|
||||
final String uuid;
|
||||
final String name;
|
||||
final int version;
|
||||
final Directory directory;
|
||||
final List<IconPackIcon> icons;
|
||||
|
||||
const IconPack({
|
||||
required this.uuid,
|
||||
required this.name,
|
||||
required this.version,
|
||||
required this.directory,
|
||||
required this.icons,
|
||||
});
|
||||
|
||||
File? getFileForIssuer(String? issuer) {
|
||||
if (issuer == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final matching = icons.where((element) =>
|
||||
element.issuer.any((element) => element == issuer.toUpperCase()));
|
||||
|
||||
final issuerImageFile = matching.isNotEmpty
|
||||
? File(join(directory.path, getLocalIconFileName(matching.first.filename)))
|
||||
: null;
|
||||
|
||||
if (issuerImageFile != null && !issuerImageFile.existsSync()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return issuerImageFile;
|
||||
}
|
||||
}
|
203
lib/oath/icon_provider/icon_pack_dialog.dart
Normal file
203
lib/oath/icon_provider/icon_pack_dialog.dart
Normal file
@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:yubico_authenticator/app/message.dart';
|
||||
import 'package:yubico_authenticator/app/state.dart';
|
||||
import 'package:yubico_authenticator/oath/icon_provider/icon_pack.dart';
|
||||
import 'package:yubico_authenticator/oath/icon_provider/icon_pack_manager.dart';
|
||||
import 'package:yubico_authenticator/widgets/responsive_dialog.dart';
|
||||
|
||||
class IconPackDialog extends ConsumerWidget {
|
||||
const IconPackDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final iconPack = ref.watch(iconPackProvider);
|
||||
return ResponsiveDialog(
|
||||
title: Text(l10n.oath_custom_icons),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_DialogDescription(),
|
||||
const SizedBox(height: 4),
|
||||
_action(iconPack, l10n),
|
||||
_loadedIconPackRow(iconPack),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _loadedIconPackRow(AsyncValue<IconPack?> iconPack) {
|
||||
return iconPack.when(
|
||||
data: (IconPack? data) =>
|
||||
(data != null) ? _IconPackDescription(data) : null,
|
||||
error: (Object error, StackTrace stackTrace) => null,
|
||||
loading: () => null);
|
||||
}
|
||||
|
||||
Widget? _action(AsyncValue<IconPack?> iconPack, AppLocalizations l10n) =>
|
||||
iconPack.when(
|
||||
data: (IconPack? data) => _ImportActionChip(data != null
|
||||
? l10n.oath_custom_icons_replace
|
||||
: l10n.oath_custom_icons_load),
|
||||
error: (Object error, StackTrace stackTrace) =>
|
||||
_ImportActionChip(l10n.oath_custom_icons_load),
|
||||
loading: () => _ImportActionChip(
|
||||
l10n.oath_custom_icons_loading,
|
||||
avatar: const CircularProgressIndicator(),
|
||||
));
|
||||
}
|
||||
|
||||
class _DialogDescription extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
text: l10n.oath_custom_icons_description,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
children: [const TextSpan(text: ' '), _createLearnMoreLink(context)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Uri get _learnMoreUri =>
|
||||
Uri.parse('https://github.com/Yubico/yubioath-flutter/blob/'
|
||||
'main/doc/Custom_Account_Icons.adoc');
|
||||
|
||||
TextSpan _createLearnMoreLink(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return TextSpan(
|
||||
text: AppLocalizations.of(context)!.oath_custom_icons_learn_more,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(color: theme.colorScheme.primary),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () async {
|
||||
await launchUrl(_learnMoreUri, mode: LaunchMode.externalApplication);
|
||||
},
|
||||
children: const [
|
||||
TextSpan(text: ' ') // without this the recognizer takes over whole row
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconPackDescription extends ConsumerWidget {
|
||||
final IconPack iconPack;
|
||||
|
||||
const _IconPackDescription(this.iconPack);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
text: iconPack.name,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
children: [TextSpan(text: ' (${iconPack.version})')]))),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: l10n.oath_custom_icons_remove,
|
||||
onPressed: () async {
|
||||
final removePackStatus =
|
||||
await ref.read(iconPackProvider.notifier).removePack();
|
||||
await ref.read(withContextProvider)(
|
||||
(context) async {
|
||||
if (removePackStatus) {
|
||||
showMessage(context,
|
||||
l10n.oath_custom_icons_icon_pack_removed);
|
||||
} else {
|
||||
showMessage(context,
|
||||
l10n.oath_custom_icons_err_icon_pack_remove);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.delete_outline)),
|
||||
],
|
||||
)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImportActionChip extends ConsumerWidget {
|
||||
final String _label;
|
||||
final Widget avatar;
|
||||
|
||||
const _ImportActionChip(this._label,
|
||||
{this.avatar = const Icon(Icons.download_outlined)});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ActionChip(
|
||||
onPressed: () async {
|
||||
_importAction(context, ref);
|
||||
},
|
||||
avatar: avatar,
|
||||
label: Text(_label));
|
||||
}
|
||||
|
||||
void _importAction(BuildContext context, WidgetRef ref) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowedExtensions: ['zip'],
|
||||
type: FileType.custom,
|
||||
allowMultiple: false,
|
||||
lockParentWindow: true,
|
||||
dialogTitle: l10n.oath_custom_icons_choose_icon_pack);
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
final importStatus = await ref
|
||||
.read(iconPackProvider.notifier)
|
||||
.importPack(l10n, result.paths.first!);
|
||||
await ref.read(withContextProvider)((context) async {
|
||||
if (importStatus) {
|
||||
showMessage(context, l10n.oath_custom_icons_icon_pack_imported);
|
||||
} else {
|
||||
showMessage(
|
||||
context,
|
||||
l10n.oath_custom_icons_err_icon_pack_import(
|
||||
ref.read(iconPackProvider.notifier).lastError ??
|
||||
l10n.oath_custom_icons_err_import_general));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
207
lib/oath/icon_provider/icon_pack_manager.dart
Normal file
207
lib/oath/icon_provider/icon_pack_manager.dart
Normal file
@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:yubico_authenticator/app/logging.dart';
|
||||
|
||||
import 'icon_cache.dart';
|
||||
import 'icon_pack.dart';
|
||||
|
||||
final _log = Logger('icon_pack_manager');
|
||||
|
||||
class IconPackManager extends StateNotifier<AsyncValue<IconPack?>> {
|
||||
final IconCache _iconCache;
|
||||
|
||||
String? _lastError;
|
||||
final _packSubDir = 'issuer_icons';
|
||||
|
||||
IconPackManager(this._iconCache) : super(const AsyncValue.data(null)) {
|
||||
readPack();
|
||||
}
|
||||
|
||||
String? get lastError => _lastError;
|
||||
|
||||
void readPack() async {
|
||||
final packDirectory = await _packDirectory;
|
||||
final packFile =
|
||||
File(join(packDirectory.path, getLocalIconFileName('pack.json')));
|
||||
|
||||
_log.debug('Looking for file: ${packFile.path}');
|
||||
|
||||
if (!await packFile.exists()) {
|
||||
_log.debug('Failed to find icons pack ${packFile.path}');
|
||||
state = AsyncValue.error(
|
||||
'Failed to find icon pack ${packFile.path}', StackTrace.current);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var packContent = await packFile.readAsString();
|
||||
Map<String, dynamic> pack = const JsonDecoder().convert(packContent);
|
||||
|
||||
final icons = List<IconPackIcon>.from(pack['icons'].map((icon) =>
|
||||
IconPackIcon(
|
||||
filename: icon['filename'],
|
||||
category: icon['category'],
|
||||
issuer: List<String>.from(icon['issuer'])
|
||||
.map((e) => e.toUpperCase())
|
||||
.toList(growable: false))));
|
||||
|
||||
state = AsyncValue.data(IconPack(
|
||||
uuid: pack['uuid'],
|
||||
name: pack['name'],
|
||||
version: pack['version'],
|
||||
directory: packDirectory,
|
||||
icons: icons));
|
||||
|
||||
_log.debug(
|
||||
'Parsed ${state.value?.name} with ${state.value?.icons.length} icons');
|
||||
} catch (e) {
|
||||
_log.debug('Failed to parse icons pack ${packFile.path}');
|
||||
state = AsyncValue.error(
|
||||
'Failed to parse icon pack ${packFile.path}', StackTrace.current);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> importPack(AppLocalizations l10n, String filePath) async {
|
||||
// remove existing pack first
|
||||
await removePack();
|
||||
|
||||
final packFile = File(filePath);
|
||||
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
if (!await packFile.exists()) {
|
||||
_log.error('Input file does not exist');
|
||||
_lastError = l10n.oath_custom_icons_err_file_not_found;
|
||||
state = AsyncValue.error('Input file does not exist', StackTrace.current);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await packFile.length() > 5 * 1024 * 1024) {
|
||||
_log.error('File size too big.');
|
||||
_lastError = l10n.oath_custom_icons_err_file_too_big;
|
||||
state = AsyncValue.error('File size too big', StackTrace.current);
|
||||
return false;
|
||||
}
|
||||
|
||||
// copy input file to temporary folder
|
||||
final tempDirectory = await Directory.systemTemp.createTemp('yubioath');
|
||||
final tempCopy =
|
||||
await packFile.copy(join(tempDirectory.path, basename(packFile.path)));
|
||||
final bytes = await File(tempCopy.path).readAsBytes();
|
||||
|
||||
final unpackDirectory = Directory(join(tempDirectory.path, 'unpack'));
|
||||
|
||||
final archive = ZipDecoder().decodeBytes(bytes, verify: true);
|
||||
for (final file in archive) {
|
||||
final filename = file.name;
|
||||
if (file.size > 0) {
|
||||
final data = file.content as List<int>;
|
||||
final extractedFile =
|
||||
File(join(unpackDirectory.path, getLocalIconFileName(filename)));
|
||||
_log.debug('Writing file: ${extractedFile.path} (size: ${file.size})');
|
||||
final createdFile = await extractedFile.create(recursive: true);
|
||||
await createdFile.writeAsBytes(data);
|
||||
}
|
||||
}
|
||||
|
||||
// check that there is pack.json
|
||||
final packJsonFile =
|
||||
File(join(unpackDirectory.path, getLocalIconFileName('pack.json')));
|
||||
if (!await packJsonFile.exists()) {
|
||||
_log.error('File is not an icon pack: missing pack.json');
|
||||
_lastError = l10n.oath_custom_icons_err_invalid_icon_pack;
|
||||
state = AsyncValue.error('File is not an icon pack', StackTrace.current);
|
||||
await _deleteDirectory(tempDirectory);
|
||||
return false;
|
||||
}
|
||||
|
||||
// test pack.json
|
||||
try {
|
||||
var packContent = await packJsonFile.readAsString();
|
||||
const JsonDecoder().convert(packContent);
|
||||
} catch (e) {
|
||||
_log.error('Failed to parse pack.json: $e');
|
||||
_lastError = l10n.oath_custom_icons_err_invalid_icon_pack;
|
||||
state = AsyncValue.error('File is not an icon pack', StackTrace.current);
|
||||
await _deleteDirectory(tempDirectory);
|
||||
return false;
|
||||
}
|
||||
|
||||
// remove old icons pack and icon pack cache
|
||||
final packDirectory = await _packDirectory;
|
||||
if (!await _deleteDirectory(packDirectory)) {
|
||||
_log.error('Failure when deleting original pack directory');
|
||||
_lastError = l10n.oath_custom_icons_err_filesystem_error;
|
||||
state = AsyncValue.error(
|
||||
'Failure deleting original pack directory', StackTrace.current);
|
||||
await _deleteDirectory(tempDirectory);
|
||||
return false;
|
||||
}
|
||||
|
||||
await _iconCache.fsCache.clear();
|
||||
_iconCache.memCache.clear();
|
||||
|
||||
// moves unpacked files to the directory final directory
|
||||
await unpackDirectory.rename(packDirectory.path);
|
||||
|
||||
readPack();
|
||||
|
||||
await _deleteDirectory(tempDirectory);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// removes imported icon pack
|
||||
Future<bool> removePack() async {
|
||||
_iconCache.memCache.clear();
|
||||
await _iconCache.fsCache.clear();
|
||||
final cleanupStatus = await _deleteDirectory(await _packDirectory);
|
||||
state = const AsyncValue.data(null);
|
||||
return cleanupStatus;
|
||||
}
|
||||
|
||||
Future<bool> _deleteDirectory(Directory directory) async {
|
||||
if (await directory.exists()) {
|
||||
await directory.delete(recursive: true);
|
||||
}
|
||||
|
||||
if (await directory.exists()) {
|
||||
_log.error('Failed to delete directory');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<Directory> get _packDirectory async {
|
||||
final supportDirectory = await getApplicationSupportDirectory();
|
||||
return Directory(join(supportDirectory.path, _packSubDir));
|
||||
}
|
||||
}
|
||||
|
||||
final iconPackProvider =
|
||||
StateNotifierProvider<IconPackManager, AsyncValue<IconPack?>>(
|
||||
(ref) => IconPackManager(ref.watch(iconCacheProvider)));
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Yubico.
|
||||
* Copyright (C) 2022-2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@ -22,6 +22,8 @@ const setOrManagePasswordAction = Key('$_prefix.set_or_manage_oath_password');
|
||||
const addAccountAction = Key('$_prefix.add_account');
|
||||
const resetAction = Key('$_prefix.reset');
|
||||
|
||||
const customIconsAction = Key('$_prefix.custom_icons');
|
||||
|
||||
const noAccountsView = Key('$_prefix.no_accounts');
|
||||
|
||||
// This is global so we can access it from the global Ctrl+F shortcut.
|
||||
|
125
lib/oath/views/account_icon.dart
Normal file
125
lib/oath/views/account_icon.dart
Normal file
@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:vector_graphics/vector_graphics.dart';
|
||||
import 'package:yubico_authenticator/oath/icon_provider/icon_file_loader.dart';
|
||||
import 'package:yubico_authenticator/oath/icon_provider/icon_pack.dart';
|
||||
import 'package:yubico_authenticator/oath/icon_provider/icon_pack_manager.dart';
|
||||
import 'package:yubico_authenticator/widgets/delayed_visibility.dart';
|
||||
|
||||
class AccountIcon extends ConsumerWidget {
|
||||
final String? issuer;
|
||||
final Widget defaultWidget;
|
||||
|
||||
static const double _width = 40;
|
||||
static const double _height = 40;
|
||||
|
||||
const AccountIcon({
|
||||
super.key,
|
||||
required this.issuer,
|
||||
required this.defaultWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final iconPack = ref.watch(iconPackProvider);
|
||||
return iconPack.when(
|
||||
data: (IconPack? iconPack) {
|
||||
final issuerImageFile = iconPack?.getFileForIssuer(issuer);
|
||||
if (issuerImageFile == null) {
|
||||
return defaultWidget;
|
||||
}
|
||||
|
||||
switch (extension(issuerImageFile.path)) {
|
||||
case '.svg':
|
||||
{
|
||||
return _decodeSvg(ref, issuerImageFile);
|
||||
}
|
||||
case '.png':
|
||||
case '.jpg':
|
||||
{
|
||||
return _decodeRasterImage(ref, issuerImageFile);
|
||||
}
|
||||
}
|
||||
return defaultWidget;
|
||||
},
|
||||
error: (_, __) => defaultWidget,
|
||||
loading: () => defaultWidget);
|
||||
}
|
||||
|
||||
Widget _decodeSvg(WidgetRef ref, File file) {
|
||||
return VectorGraphic(
|
||||
width: _width,
|
||||
height: _height,
|
||||
fit: BoxFit.fill,
|
||||
loader: IconFileLoader(ref, file),
|
||||
placeholderBuilder: (BuildContext _) {
|
||||
return DelayedVisibility(
|
||||
delay: const Duration(milliseconds: 10),
|
||||
child: Stack(alignment: Alignment.center, children: [
|
||||
Opacity(
|
||||
opacity: 0.5,
|
||||
child: defaultWidget,
|
||||
),
|
||||
const CircularProgressIndicator(),
|
||||
]),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _decodeRasterImage(WidgetRef ref, File file) {
|
||||
return ClipOval(
|
||||
// This clipper makes the oval small enough to hide artifacts
|
||||
// on the oval border for images not supporting transparency.
|
||||
clipper: _AccountIconClipper(_width, _height),
|
||||
child: Image.file(
|
||||
file,
|
||||
filterQuality: FilterQuality.medium,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.center,
|
||||
width: _width,
|
||||
height: _height,
|
||||
errorBuilder: (_, __, ___) => defaultWidget,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountIconClipper extends CustomClipper<Rect> {
|
||||
final double _width;
|
||||
final double _height;
|
||||
|
||||
_AccountIconClipper(this._width, this._height);
|
||||
|
||||
@override
|
||||
Rect getClip(Size size) {
|
||||
return Rect.fromCenter(
|
||||
center: Offset(_width / 2, _height / 2),
|
||||
// make the rect smaller to hide artifacts
|
||||
width: _width - 1,
|
||||
height: _height - 1);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(oldClipper) {
|
||||
return true;
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Yubico.
|
||||
* Copyright (C) 2022-2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@ -27,6 +27,7 @@ import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'account_dialog.dart';
|
||||
import 'account_helper.dart';
|
||||
import 'account_icon.dart';
|
||||
import 'actions.dart';
|
||||
import 'delete_account_dialog.dart';
|
||||
import 'rename_account_dialog.dart';
|
||||
@ -153,6 +154,20 @@ class _AccountViewState extends ConsumerState<AccountView> {
|
||||
final showAvatar = constraints.maxWidth >= 315;
|
||||
|
||||
final subtitle = helper.subtitle;
|
||||
|
||||
final circleAvatar = CircleAvatar(
|
||||
foregroundColor: darkMode ? Colors.black : Colors.white,
|
||||
backgroundColor: _iconColor(darkMode ? 300 : 400),
|
||||
child: Text(
|
||||
(credential.issuer ?? credential.name)
|
||||
.characters
|
||||
.first
|
||||
.toUpperCase(),
|
||||
style:
|
||||
const TextStyle(fontSize: 16, fontWeight: FontWeight.w300),
|
||||
),
|
||||
);
|
||||
|
||||
return Shortcuts(
|
||||
shortcuts: {
|
||||
LogicalKeySet(LogicalKeyboardKey.enter): const OpenIntent(),
|
||||
@ -187,18 +202,8 @@ class _AccountViewState extends ConsumerState<AccountView> {
|
||||
Actions.maybeInvoke(context, const CopyIntent());
|
||||
},
|
||||
leading: showAvatar
|
||||
? CircleAvatar(
|
||||
foregroundColor: darkMode ? Colors.black : Colors.white,
|
||||
backgroundColor: _iconColor(darkMode ? 300 : 400),
|
||||
child: Text(
|
||||
(credential.issuer ?? credential.name)
|
||||
.characters
|
||||
.first
|
||||
.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w300),
|
||||
),
|
||||
)
|
||||
? AccountIcon(
|
||||
issuer: credential.issuer, defaultWidget: circleAvatar)
|
||||
: null,
|
||||
title: Text(
|
||||
helper.title,
|
||||
|
@ -19,6 +19,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:yubico_authenticator/oath/icon_provider/icon_pack_dialog.dart';
|
||||
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
@ -91,6 +92,22 @@ Widget oathBuildActions(
|
||||
),
|
||||
ListTitle(AppLocalizations.of(context)!.general_manage,
|
||||
textStyle: Theme.of(context).textTheme.bodyLarge),
|
||||
ListTile(
|
||||
key: keys.customIconsAction,
|
||||
title: const Text('Custom icons'),
|
||||
subtitle: const Text('Set icons for accounts'),
|
||||
leading: const CircleAvatar(
|
||||
child: Icon(Icons.image_outlined),
|
||||
),
|
||||
onTap: () async {
|
||||
Navigator.of(context).pop();
|
||||
await ref.read(withContextProvider)((context) => showBlurDialog(
|
||||
context: context,
|
||||
routeSettings:
|
||||
const RouteSettings(name: 'oath_icon_pack_dialog'),
|
||||
builder: (context) => const IconPackDialog(),
|
||||
));
|
||||
}),
|
||||
ListTile(
|
||||
key: keys.setOrManagePasswordAction,
|
||||
title: Text(oathState.hasKey
|
||||
|
@ -7,6 +7,7 @@ import Foundation
|
||||
|
||||
import desktop_drop
|
||||
import local_notifier
|
||||
import path_provider_foundation
|
||||
import screen_retriever
|
||||
import shared_preferences_foundation
|
||||
import tray_manager
|
||||
@ -16,6 +17,7 @@ import window_manager
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
||||
LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin"))
|
||||
|
@ -4,6 +4,9 @@ PODS:
|
||||
- FlutterMacOS (1.0.0)
|
||||
- local_notifier (0.1.0):
|
||||
- FlutterMacOS
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- screen_retriever (0.0.1):
|
||||
- FlutterMacOS
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
@ -20,6 +23,7 @@ DEPENDENCIES:
|
||||
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`)
|
||||
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/macos`)
|
||||
- screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`)
|
||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/macos`)
|
||||
- tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`)
|
||||
@ -33,6 +37,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral
|
||||
local_notifier:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/local_notifier/macos
|
||||
path_provider_foundation:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/macos
|
||||
screen_retriever:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos
|
||||
shared_preferences_foundation:
|
||||
@ -48,6 +54,7 @@ SPEC CHECKSUMS:
|
||||
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
|
||||
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
|
||||
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
|
||||
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852
|
||||
screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38
|
||||
shared_preferences_foundation: 986fc17f3d3251412d18b0265f9c64113a8c2472
|
||||
tray_manager: 9064e219c56d75c476e46b9a21182087930baf90
|
||||
|
96
pubspec.lock
96
pubspec.lock
@ -18,7 +18,7 @@ packages:
|
||||
source: hosted
|
||||
version: "5.6.0"
|
||||
archive:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: archive
|
||||
sha256: "80e5141fafcb3361653ce308776cfd7d45e6e9fbb429e14eec571382c0c5fecb"
|
||||
@ -170,7 +170,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.3.3+4"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67
|
||||
@ -217,6 +217,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.4"
|
||||
file_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: d090ae03df98b0247b82e5928f44d1b959867049d18d73635e2e0bc3f49542b9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.5"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -248,6 +256,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: "60fc7b78455b94e6de2333d2f95196d32cf5c22f4b0b0520a628804cb463503b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.7"
|
||||
flutter_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -445,13 +461,45 @@ packages:
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: db9d4f58c908a4ba5953fcee2ae317c94889433e5024c27ce74a37f94267945b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.8.2"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: dcea5feb97d8abf90cab9e9030b497fb7c3cbf26b7a1fe9e3ef7dcb0a1ddec95
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.12"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: a776c088d671b27f6e3aa8881d64b87b3e80201c64e8869b811325de7a76c15e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.22"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "62a68e7e1c6c459f9289859e2fae58290c981ce21d1697faf54910fe1faa4c74"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -476,6 +524,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "49392a45ced973e8d94a85fdb21293fbb40ba805fc49f2965101ae748a3683b4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -816,6 +872,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
vector_graphics:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: vector_graphics
|
||||
sha256: "09562ef5f47aa84f6567495adb6b9cb2a3192b82c352623b8bd00b300d62603b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
vector_graphics_codec:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_codec
|
||||
sha256: "886e57742644ebed024dc3ade29712e37eea1b03d294fb314c0a3386243fe5a6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
vector_graphics_compiler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: "5d9010c4a292766c55395b2288532579a85673f8148460d1e233d98ffe10d24e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -880,6 +960,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "979ee37d622dec6365e2efa4d906c37470995871fe9ae080d967e192d88286b5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.2"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -890,4 +978,4 @@ packages:
|
||||
version: "3.1.1"
|
||||
sdks:
|
||||
dart: ">=2.19.0 <3.0.0"
|
||||
flutter: ">=3.3.0"
|
||||
flutter: ">=3.5.0-0"
|
||||
|
@ -53,6 +53,13 @@ dependencies:
|
||||
path: android/flutter_plugins/qrscanner_zxing
|
||||
desktop_drop: ^0.4.0
|
||||
url_launcher: ^6.1.7
|
||||
path_provider: ^2.0.12
|
||||
vector_graphics: ^1.0.1
|
||||
vector_graphics_compiler: ^1.0.1
|
||||
path: ^1.8.2
|
||||
file_picker: ^5.2.5
|
||||
archive: ^3.3.2
|
||||
crypto: ^3.0.2
|
||||
tray_manager: ^0.2.0
|
||||
local_notifier: ^0.1.5
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user