Zap UX improvements

This commit is contained in:
franzap
2025-01-02 21:35:40 -03:00
parent 5533b38ad1
commit b51507200d
11 changed files with 286 additions and 308 deletions
+22 -24
View File
@@ -22,13 +22,17 @@ class User extends base.User with DataModelMixin<User> {
{required base.Event event, String? comment}) async {
final adapter = DataModel.adapterFor(this) as NostrAdapter;
// First ensure connection is available
final nwcConnection = adapter.ref.read(nwcConnectionProvider).asData?.value;
if (nwcConnection == null) {
throw 'No NWC connection';
}
final authorPubkey = event.event.pubkey;
print(
'bro wants to zap $amountInSats to $authorPubkey for ${event.event.id}');
final author = adapter.ref.users.findOneLocalById(authorPubkey);
if (author?.lud16 == null) {
throw 'cant zap';
throw 'Recipient has no Lightning address';
}
final amountInMillisats = amountInSats * 1000;
@@ -44,27 +48,27 @@ class User extends base.User with DataModelMixin<User> {
await partialZapRequest.signWith(amberSigner, withPubkey: pubkey);
// Now we fetch the invoice
final lnurlResponse = await author.fetchLnUrl();
final lnResponse = await author.fetchLightningAddress();
final recipientAllowsNostr = lnurlResponse['allowsNostr'];
final recipientPubkey = lnurlResponse['nostrPubkey'];
final recipientAllowsNostr = lnResponse['allowsNostr'];
final recipientPubkey = lnResponse['nostrPubkey'];
// All amounts are in millisats
final recipientMin = lnurlResponse['minSendable'];
final recipientMax = lnurlResponse['maxSendable'];
final recipientMin = lnResponse['minSendable'];
final recipientMax = lnResponse['maxSendable'];
if (recipientAllowsNostr != true && recipientPubkey != null) {
throw 'cant zap, recipient does not allow nostr';
throw 'Recipient does not allow nostr';
}
if ((recipientMin != null && amountInMillisats < recipientMin) ||
(recipientMax != null && amountInMillisats > recipientMax)) {
throw 'cant zap, amount not between min and max sendable';
throw 'Amount not between min and max sendable';
}
final commentLength = lnurlResponse['commentAllowed'];
final commentLength = lnResponse['commentAllowed'];
comment =
commentLength != null ? comment?.substringMax(commentLength) : comment;
var callbackUri = Uri.parse(lnurlResponse['callback']!);
var callbackUri = Uri.parse(lnResponse['callback']!);
callbackUri = callbackUri.replace(
queryParameters: {
...callbackUri.queryParameters,
@@ -74,26 +78,20 @@ class User extends base.User with DataModelMixin<User> {
},
);
final response = await http.get(callbackUri);
final invoiceMap = jsonDecode(response.body);
final invoiceResponse = await http.get(callbackUri);
final invoiceMap = jsonDecode(invoiceResponse.body);
final invoice = invoiceMap['pr'];
if (invoice == null) {
throw 'cant zap, no invoice';
throw 'No invoice';
}
final nwcConnection = adapter.ref.read(nwcConnectionProvider).asData?.value;
if (nwcConnection == null) {
throw 'cant zap, no nwc connection';
}
// Pay the invoice via NWC
final rw = await adapter.socialRelays.ndk!.nwc.payInvoice(nwcConnection,
// Pay the invoice via NWC (errors are thrown)
await adapter.socialRelays.ndk!.nwc.payInvoice(nwcConnection,
invoice: invoice, timeout: Duration(seconds: 20));
print(rw);
}
Future<Map<String, dynamic>> fetchLnUrl() async {
Future<Map<String, dynamic>> fetchLightningAddress() async {
final [userName, domainName] = lud16!.split('@');
final lnurl = 'https://$domainName/.well-known/lnurlp/$userName';
final response = await http.get(Uri.parse(lnurl));
+1 -1
View File
@@ -16,8 +16,8 @@ import 'package:zapstore/widgets/install_button.dart';
import 'package:zapstore/widgets/release_card.dart';
import 'package:zapstore/widgets/signer_and_developer_row.dart';
import 'package:zapstore/widgets/spinning_logo.dart';
import 'package:zapstore/widgets/users_rich_text.dart';
import 'package:zapstore/widgets/versioned_app_header.dart';
import 'package:zapstore/widgets/wot_container.dart';
import 'package:zapstore/widgets/zap_button.dart';
import 'package:zapstore/widgets/zap_receipts.dart';
+80 -65
View File
@@ -10,6 +10,7 @@ import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:ndk/domain_layer/usecases/nwc/nwc_connection.dart';
import 'package:path_provider/path_provider.dart';
import 'package:purplebase/purplebase.dart';
import 'package:zapstore/main.data.dart';
@@ -29,15 +30,11 @@ class SettingsScreen extends HookConsumerWidget {
final systemInfoState = ref.watch(systemInfoNotifierProvider);
final feedbackController = useTextEditingController();
final nwcController = useTextEditingController();
final user = ref.settings
.watchOne('_', alsoWatch: (_) => {_.user})
.model!
.user
.value;
final isTextFieldEmpty = useState(true);
final nwcConnection = ref.watch(nwcConnectionProvider);
return SingleChildScrollView(
child: Column(
@@ -116,67 +113,7 @@ class SettingsScreen extends HookConsumerWidget {
Gap(40),
Divider(),
Gap(40),
if (user != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gap(20),
Text(
"Nostr Wallet Connect ${nwcConnection.value != null ? 'connected' : 'URI'}"),
if (nwcConnection.value != null)
ElevatedButton(
onPressed: () async {
await ref
.read(nwcConnectionProvider.notifier)
.disconnectWallet();
},
style: ElevatedButton.styleFrom(
disabledBackgroundColor: Colors.transparent,
backgroundColor: Colors.transparent),
child: Text('Disconnect'),
),
if (nwcConnection.value == null)
TextField(
autocorrect: false,
controller: nwcController,
onChanged: (value) {
isTextFieldEmpty.value = value.isEmpty;
},
decoration: InputDecoration(
hintText: 'nostr+walletconnect://...',
suffixIcon: AsyncButtonBuilder(
disabled: isTextFieldEmpty.value,
loadingWidget: SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(),
),
onPressed: () async {
final nwcUri = nwcController.text.trim();
await ref
.read(nwcConnectionProvider.notifier)
.connectWallet(nwcUri);
},
builder: (context, child, callback, buttonState) {
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SizedBox(
child: ElevatedButton(
onPressed: callback,
style: ElevatedButton.styleFrom(
disabledBackgroundColor: Colors.transparent,
backgroundColor: Colors.transparent),
child: child,
),
),
);
},
child: Text('Connect'),
),
),
),
],
),
if (user != null) NostrWalletConnectContainer(),
Gap(40),
Divider(),
Gap(40),
@@ -280,3 +217,81 @@ class SettingsScreen extends HookConsumerWidget {
);
}
}
class NostrWalletConnectContainer extends HookConsumerWidget {
final bool dialogMode;
const NostrWalletConnectContainer({
super.key,
this.dialogMode = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final nwcConnection = ref.watch(nwcConnectionProvider);
final controller = useTextEditingController(text: '');
final isTextFieldEmpty = useState(true);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gap(20),
Text(
"Nostr Wallet Connect ${nwcConnection.value != null ? 'connected' : 'URI'}"),
if (nwcConnection.value != null && !dialogMode)
ElevatedButton(
onPressed: () async {
await ref.read(nwcConnectionProvider.notifier).disconnectWallet();
},
style: ElevatedButton.styleFrom(
disabledBackgroundColor: Colors.transparent,
backgroundColor: Colors.transparent),
child: Text('Disconnect'),
),
if (nwcConnection.value == null)
TextField(
autocorrect: false,
controller: controller,
onChanged: (value) {
isTextFieldEmpty.value = value.isEmpty;
},
decoration: InputDecoration(
hintText: 'nostr+walletconnect://...',
suffixIcon: AsyncButtonBuilder(
disabled: isTextFieldEmpty.value,
loadingWidget: SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(),
),
onPressed: () async {
final nwcUri = controller.text.trim();
await ref
.read(nwcConnectionProvider.notifier)
.connectWallet(nwcUri);
if (dialogMode && context.mounted) {
Navigator.of(context).pop();
}
},
builder: (context, child, callback, buttonState) {
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SizedBox(
child: ElevatedButton(
onPressed: callback,
style: ElevatedButton.styleFrom(
disabledBackgroundColor: Colors.transparent,
backgroundColor: Colors.transparent),
child: child,
),
),
);
},
child: Text('Connect'),
),
),
),
],
);
}
}
+10 -12
View File
@@ -2,15 +2,21 @@ import 'package:flutter_data/flutter_data.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:ndk/domain_layer/usecases/nwc/consts/nwc_method.dart';
import 'package:ndk/ndk.dart';
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/user.dart';
const kNwcSecretKey = 'nwc_secret';
class NwcConnectionNotifier extends StateNotifier<AsyncValue<NwcConnection?>> {
Ref ref;
late Ndk ndk;
final _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
NwcConnectionNotifier() : super(AsyncData(null)) {
NwcConnectionNotifier(this.ref) : super(AsyncLoading()) {
ndk = ref.users.nostrAdapter.socialRelays.ndk!;
// Attempt to load from local storage on initialization
_storage
.read(key: kNwcSecretKey)
@@ -32,13 +38,13 @@ class NwcConnectionNotifier extends StateNotifier<AsyncValue<NwcConnection?>> {
// If secret is supplied and a connection is still active,
// it means it is a new connection string so disconnect NWC
if (state.value != null) {
await ndkForNwc.nwc.disconnect(state.value!);
await ndk.nwc.disconnect(state.value!);
}
// Write secret to storage and connect
await _storage.write(key: kNwcSecretKey, value: nwcSecret);
final connection = await ndkForNwc.nwc.connect(
final connection = await ndk.nwc.connect(
nwcSecret,
doGetInfoMethod: false,
onError: (error) {
@@ -61,12 +67,4 @@ class NwcConnectionNotifier extends StateNotifier<AsyncValue<NwcConnection?>> {
final nwcConnectionProvider =
StateNotifierProvider<NwcConnectionNotifier, AsyncValue<NwcConnection?>>(
(ref) => NwcConnectionNotifier());
final ndkForNwc = Ndk(
NdkConfig(
cache: MemCacheManager(),
eventVerifier: Bip340EventVerifier(),
bootstrapRelays: [],
logLevel: LogLevels().info),
);
(ref) => NwcConnectionNotifier(ref));
+71 -62
View File
@@ -67,7 +67,7 @@ class SignInButton extends ConsumerWidget {
label,
style: TextStyle(fontWeight: FontWeight.bold),
),
content: SignInContainer(),
content: SignInDialogBox(),
),
);
} else {
@@ -81,8 +81,9 @@ class SignInButton extends ConsumerWidget {
}
}
class SignInContainer extends HookConsumerWidget {
const SignInContainer({super.key});
class SignInDialogBox extends HookConsumerWidget {
final bool publicKeyAllowed;
const SignInDialogBox({super.key, this.publicKeyAllowed = true});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -155,73 +156,81 @@ class SignInContainer extends HookConsumerWidget {
settings.saveLocal();
if (context.mounted) {
Navigator.of(context).pop();
Navigator.of(context).pop(user);
}
},
child: Text('Sign in with Amber'),
),
Gap(10),
Text('or'),
Gap(10),
],
),
Text('Input an npub or nostr address\n(read-only):'),
TextField(
autocorrect: false,
controller: controller,
onChanged: (value) {
isTextFieldEmpty.value = value.isEmpty;
},
),
Gap(10),
AsyncButtonBuilder(
disabled: isTextFieldEmpty.value,
loadingWidget: SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(),
if (amberSigner.isAvailable && publicKeyAllowed)
Padding(
padding: const EdgeInsets.all(10),
child: Text('or'),
),
onPressed: () async {
try {
final input = controller.text.trim();
if (input.startsWith('nsec')) {
controller.clear();
throw Exception('Never give away your nsec!');
}
final user = await ref.users.findOne(input);
final settings = ref.settings.findOneLocalById('_')!;
settings.signInMethod = SignInMethod.pubkey;
settings.user.value = user;
settings.saveLocal();
if (context.mounted) {
Navigator.pop(context);
}
} on Exception catch (e, stack) {
if (context.mounted) {
context.showError(
title: e.toString(),
description: stack.toString().substringMax(200));
}
}
},
builder: (context, child, callback, buttonState) {
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SizedBox(
child: ElevatedButton(
onPressed: callback,
style: ElevatedButton.styleFrom(
disabledBackgroundColor: Colors.transparent,
backgroundColor: Colors.transparent),
child: child,
),
if (publicKeyAllowed)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Input an npub or nostr address\n(read-only):'),
TextField(
autocorrect: false,
controller: controller,
onChanged: (value) {
isTextFieldEmpty.value = value.isEmpty;
},
),
);
},
child: Text('Sign in with public key'),
)
Gap(10),
AsyncButtonBuilder(
disabled: isTextFieldEmpty.value,
loadingWidget: SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(),
),
onPressed: () async {
try {
final input = controller.text.trim();
if (input.startsWith('nsec')) {
controller.clear();
throw Exception('Never give away your nsec!');
}
final user = await ref.users.findOne(input);
final settings = ref.settings.findOneLocalById('_')!;
settings.signInMethod = SignInMethod.pubkey;
settings.user.value = user;
settings.saveLocal();
if (context.mounted) {
Navigator.pop(context);
}
} on Exception catch (e, stack) {
if (context.mounted) {
context.showError(
title: e.toString(),
description: stack.toString().substringMax(200));
}
}
},
builder: (context, child, callback, buttonState) {
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SizedBox(
child: ElevatedButton(
onPressed: callback,
style: ElevatedButton.styleFrom(
disabledBackgroundColor: Colors.transparent,
backgroundColor: Colors.transparent),
child: child,
),
),
);
},
child: Text('Sign in with public key'),
),
],
),
],
),
],
+53
View File
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/widgets/rounded_image.dart';
class UsersRichText extends StatelessWidget {
const UsersRichText({
super.key,
this.preSpan,
this.trailingText,
required this.users,
});
final TextSpan? preSpan;
final String? trailingText;
final List<User> users;
@override
Widget build(BuildContext context) {
return RichText(
text: TextSpan(
children: [
if (preSpan != null) preSpan!,
for (final user in users)
TextSpan(
style: TextStyle(height: 1.6),
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
RoundedImage(url: user.avatarUrl, size: 20),
Text(
' ${user.nameOrNpub}',
style: TextStyle(fontWeight: FontWeight.bold),
),
Text(users.indexOf(user) == users.length - 1
? ''
: (users.indexOf(user) == users.length - 2
? ' and '
: ', ')),
],
),
),
if (users.indexOf(user) == users.length - 1)
TextSpan(text: trailingText, style: TextStyle(fontSize: 15))
],
),
],
),
);
}
}
+1 -51
View File
@@ -6,7 +6,7 @@ import 'package:purplebase/purplebase.dart' as base;
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/settings.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/widgets/rounded_image.dart';
import 'package:zapstore/widgets/users_rich_text.dart';
class WebOfTrustContainer extends HookConsumerWidget {
const WebOfTrustContainer({
@@ -68,56 +68,6 @@ class WebOfTrustContainer extends HookConsumerWidget {
}
}
class UsersRichText extends StatelessWidget {
const UsersRichText({
super.key,
this.preSpan,
this.trailingText,
required this.users,
});
final TextSpan? preSpan;
final String? trailingText;
final List<User> users;
@override
Widget build(BuildContext context) {
return RichText(
text: TextSpan(
children: [
if (preSpan != null) preSpan!,
for (final user in users)
TextSpan(
style: TextStyle(height: 1.6),
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
RoundedImage(url: user.avatarUrl, size: 20),
Text(
' ${user.nameOrNpub}',
style: TextStyle(fontWeight: FontWeight.bold),
),
Text(users.indexOf(user) == users.length - 1
? ''
: (users.indexOf(user) == users.length - 2
? ' and '
: ', ')),
],
),
),
if (users.indexOf(user) == users.length - 1)
TextSpan(text: trailingText, style: TextStyle(fontSize: 15))
],
),
],
),
);
}
}
final followsWhoFollowProvider = FutureProvider.autoDispose
.family<List<User>, ({String from, String to})>((ref, arg) async {
final _ =
+40 -62
View File
@@ -1,12 +1,12 @@
import 'package:async_button_builder/async_button_builder.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:zapstore/main.data.dart';
import 'package:zapstore/models/app.dart';
import 'package:zapstore/models/settings.dart';
import 'package:zapstore/navigation/router.dart';
import 'package:zapstore/screens/settings_screen.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/sign_in_container.dart';
import '../utils/nwc.dart';
@@ -21,7 +21,6 @@ class ZapButton extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final nwcConnection = ref.watch(nwcConnectionProvider);
// final zapStatus = ref.watch(zapProvider);
final user = ref.settings
.watchOne('_', alsoWatch: (_) => {_.user})
.model!
@@ -30,61 +29,45 @@ class ZapButton extends HookConsumerWidget {
final amountController = TextEditingController();
// We use value as default text, for progress only interested in loading/error substates
// final zapButtonText = switch (zapStatus) {
// AsyncLoading() => 'Zapping... ⚡⚡⚡',
// AsyncError() => 'Error zapping!',
// AsyncValue() => 'Zap the dev ⚡',
// };
// Show toast if could not zap
// useMemoized(() {
// WidgetsBinding.instance.addPostFrameCallback((_) {
// if (zapStatus.value != null) {
// // final receipt = await zapStatus.value!.zapReceipt;
// // TODO: zapStatus should include the amount of sats without needing async
// // I could read zapReceiptsNotifier's state, but how I know it's this one?
// context.showInfo('Zapped!',
// description: 'You just sent the dev some sats');
// }
// if (zapStatus.hasError) {
// context.showError(
// title: 'Could not zap',
// description: 'Error sending zap: ${zapStatus.error}');
// }
// });
// }, [zapStatus.value, zapStatus.error]);
return app.developer.value != null
? AsyncButtonBuilder(
loadingWidget: SizedBox(
width: 14, height: 14, child: CircularProgressIndicator()),
return app.developer.value?.lud16 != null
? ElevatedButton(
onPressed: () async {
if (user == null) {
context.showError(
title: 'Not signed in',
description: 'Please sign in to zap');
final loggedInUser = user ??
await showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text(
'Sign in to zap',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: SignInDialogBox(publicKeyAllowed: false),
),
);
if (loggedInUser == null) {
return;
}
if (nwcConnection.value == null) {
context.showError(
title: 'No wallet connected',
description: 'Please connect your NWC wallet');
appRouter.go('/settings');
return;
await showDialog(
// ignore: use_build_context_synchronously
context: context,
builder: (_) => AlertDialog(
title: Text(
'Connect your wallet to zap',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: NostrWalletConnectContainer(dialogMode: true),
),
);
}
final developer = app.developer.value!;
if (developer.lud16 == null) {
context.showError(
title: 'Unable to zap',
description:
'The developer (${developer.nameOrNpub}) does not have a Lightning address');
if (ref.read(nwcConnectionProvider).value == null) {
return;
}
final amount = await showDialog<int>(
// ignore: use_build_context_synchronously
context: context,
builder: (context) {
return AlertDialog(
@@ -145,23 +128,18 @@ class ZapButton extends HookConsumerWidget {
0;
if (amount > 0) {
// User should be able to sign any event
// Proposed API:
await user.zap(amount,
event: app.latestMetadata!, comment: 'From zapstore!');
// final lnurl = developer.lud16!;
// await ref.read(zapProvider.notifier).zap(
// user: user,
// lnurl: lnurl,
// eventId: app.latestMetadata!.id.toString(),
// amount: amount,
// pubKey: developer.pubkey);
try {
// ignore: use_build_context_synchronously
context.showInfo('Zap sent!',
description: '$amount sats on their way');
await loggedInUser.zap(amount, event: app.latestMetadata!);
} catch (e) {
// ignore: use_build_context_synchronously
context.showError(
title: 'Unable to zap', description: e.toString());
}
}
},
builder: (context, child, callback, state) {
return ElevatedButton(onPressed: callback, child: child);
},
child: Text('Zap!'),
)
: Container();
+5 -28
View File
@@ -5,12 +5,11 @@ import 'package:ndk/domain_layer/entities/nip_01_event.dart';
import 'package:zapstore/main.data.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:zapstore/models/file_metadata.dart';
import 'package:zapstore/models/user.dart';
import 'package:zapstore/models/zap_receipt.dart';
import 'package:zapstore/widgets/rounded_image.dart';
import 'package:zapstore/widgets/users_rich_text.dart';
class ZapReceipts extends HookConsumerWidget {
ZapReceipts({
@@ -39,32 +38,10 @@ class ZapReceipts extends HookConsumerWidget {
final senderIds = receipts.map((r) => r.senderPubkey).toSet();
final senders = ref.users.findManyLocalByIds(senderIds);
return Row(
children: [
Text("⚡ $totalAmountInSats sats (${receipts.length} zaps)"),
Gap(5),
RichText(
text: TextSpan(
children: [
for (final user in senders)
TextSpan(
style: TextStyle(height: 1.6),
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
RoundedImage(url: user.avatarUrl, size: 20),
],
),
),
],
)
],
),
)
],
return UsersRichText(
preSpan: TextSpan(text: '⚡ $totalAmountInSats sats by'),
trailingText: ' and others',
users: senders,
);
}
}
+2 -2
View File
@@ -810,10 +810,10 @@ packages:
dependency: "direct main"
description:
name: ndk
sha256: e17cb3aad2936c62978891e0d3db4dc37f3ded7efbdf89e571c3927158da9135
sha256: "07c284a14e7800513743a84c911a546dcfcdc0c0ce2def4895c4e4c2286f159e"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
version: "0.2.4"
octo_image:
dependency: transitive
description:
+1 -1
View File
@@ -53,7 +53,7 @@ dependencies:
mutex: ^3.1.0
easy_image_viewer: ^1.5.1
flutter_secure_storage: ^9.2.2
ndk: ^0.2.2
ndk: ^0.2.4
dependency_overrides:
purplebase: