Update example app on Flutter plugin (#220)

* Update example app on Flutter plugin

* Expose `empty_wallet_cache` through Dart bindings (#224)
This commit is contained in:
Erdem Yerebasmaz
2024-05-24 12:12:29 +03:00
committed by GitHub
parent c7b46314f4
commit 7b1b78a2d9
38 changed files with 1821 additions and 167 deletions

View File

@@ -0,0 +1,108 @@
import 'package:bip39/bip39.dart';
import 'package:flutter/material.dart';
import 'package:flutter_breez_liquid/flutter_breez_liquid.dart';
import 'package:flutter_breez_liquid_example/routes/connect/restore_page.dart';
import 'package:flutter_breez_liquid_example/routes/home/home_page.dart';
import 'package:flutter_breez_liquid_example/services/credentials_manager.dart';
import 'package:path_provider/path_provider.dart';
class ConnectPage extends StatefulWidget {
final CredentialsManager credentialsManager;
const ConnectPage({super.key, required this.credentialsManager});
@override
State<ConnectPage> createState() => _ConnectPageState();
}
class _ConnectPageState extends State<ConnectPage> {
bool connecting = false;
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Breez Liquid SDK Demo'),
foregroundColor: Colors.blue,
),
body: Center(
child: connecting
? const CircularProgressIndicator(color: Colors.blue)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: SizedBox(
width: 200,
child: ElevatedButton(
child: const Text("Create new wallet"),
onPressed: () async {
await createWallet();
},
),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: SizedBox(
width: 200,
child: ElevatedButton(
child: const Text("Restore from backup"),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) {
return RestorePage(
onRestore: (mnemonic) async {
return await createWallet(mnemonic: mnemonic);
},
);
},
),
);
},
),
),
)
],
),
),
),
);
}
Future<Null> createWallet({String? mnemonic}) async {
final walletMnemonic = mnemonic ??= generateMnemonic(strength: 128);
debugPrint("${mnemonic.isEmpty ? "Creating" : "Restoring"} wallet with $walletMnemonic");
return await initializeWallet(mnemonic: walletMnemonic).then(
(liquidSDK) async {
await widget.credentialsManager.storeMnemonic(mnemonic: walletMnemonic).then((_) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (BuildContext context) => HomePage(
liquidSDK: liquidSDK,
credentialsManager: widget.credentialsManager,
),
),
);
});
},
);
}
Future<BindingLiquidSdk> initializeWallet({
required String mnemonic,
Network network = Network.liquid,
}) async {
final dataDir = await getApplicationDocumentsDirectory();
final req = ConnectRequest(
mnemonic: mnemonic,
dataDir: dataDir.path,
network: network,
);
return await connect(req: req);
}
}

View File

@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class RestorePage extends StatefulWidget {
final Future Function(String mnemonic) onRestore;
const RestorePage({super.key, required this.onRestore});
@override
State<RestorePage> createState() => _RestorePageState();
}
class _RestorePageState extends State<RestorePage> {
final _formKey = GlobalKey<FormState>();
List<TextEditingController> textFieldControllers =
List<TextEditingController>.generate(12, (_) => TextEditingController());
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
backgroundColor: Colors.white,
actions: [
IconButton(
onPressed: () async {
final clipboardData = await Clipboard.getData('text/plain');
final clipboardMnemonics = clipboardData?.text?.split(" ");
if (clipboardMnemonics?.length == 12) {
for (var i = 0; i < clipboardMnemonics!.length; i++) {
textFieldControllers.elementAt(i).text = clipboardMnemonics.elementAt(i);
}
}
},
icon: const Icon(Icons.paste, color: Colors.blue),
),
],
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Form(
key: _formKey,
child: GridView.builder(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: MediaQuery.of(context).size.width / 2,
childAspectRatio: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: 12,
itemBuilder: (BuildContext context, int index) {
return TextFormField(
decoration: InputDecoration(labelText: "${index + 1}"),
inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r"\s\b|\b\s"))],
validator: (String? value) {
if (value == null || value.isEmpty) {
return 'Please enter value';
}
return null;
},
controller: textFieldControllers[index],
);
},
),
),
),
bottomNavigationBar: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 40.0,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
elevation: 0.0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
),
onPressed: () async {
if (_formKey.currentState?.validate() ?? false) {
final mnemonic = textFieldControllers
.map((controller) => controller.text.toLowerCase().trim())
.toList()
.join(" ");
widget.onRestore(mnemonic);
}
},
child: Text(
"RESTORE",
textAlign: TextAlign.center,
style: Theme.of(context).primaryTextTheme.titleMedium,
maxLines: 1,
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,114 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_breez_liquid/flutter_breez_liquid.dart';
import 'package:flutter_breez_liquid_example/routes/home/widgets/balance.dart';
import 'package:flutter_breez_liquid_example/routes/home/widgets/bottom_app_bar.dart';
import 'package:flutter_breez_liquid_example/routes/home/widgets/drawer.dart';
import 'package:flutter_breez_liquid_example/routes/home/widgets/mnemonics_dialog.dart';
import 'package:flutter_breez_liquid_example/routes/home/widgets/payment_list/payment_list.dart';
import 'package:flutter_breez_liquid_example/routes/home/widgets/qr_scan_action_button.dart';
import 'package:flutter_breez_liquid_example/services/credentials_manager.dart';
class HomePage extends StatefulWidget {
final CredentialsManager credentialsManager;
final BindingLiquidSdk liquidSDK;
const HomePage({super.key, required this.credentialsManager, required this.liquidSDK});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
Stream<GetInfoResponse> walletInfoStream() async* {
debugPrint("Initialized walletInfoStream");
GetInfoRequest req = const GetInfoRequest(withScan: false);
yield await widget.liquidSDK.getInfo(req: req);
while (true) {
await Future.delayed(const Duration(seconds: 10));
yield await widget.liquidSDK.getInfo(req: req);
debugPrint("Refreshed wallet info");
}
}
Stream<List<Payment>> paymentsStream() async* {
debugPrint("Initialized paymentsStream");
yield await widget.liquidSDK.listPayments();
while (true) {
await Future.delayed(const Duration(seconds: 10));
yield await widget.liquidSDK.listPayments();
debugPrint("Refreshed payments");
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Breez Liquid SDK Demo'),
titleTextStyle: const TextStyle(fontSize: 16.0, color: Colors.blue),
backgroundColor: Colors.white,
foregroundColor: Colors.blue,
leading: Builder(
builder: (context) {
return IconButton(
icon: const Icon(Icons.menu),
onPressed: () {
Scaffold.of(context).openDrawer();
},
);
},
),
actions: [],
),
body: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Container(
height: constraints.maxHeight * 0.3,
color: Colors.white,
child: Balance(walletInfoStream: walletInfoStream()),
),
Container(
height: constraints.maxHeight * 0.7,
color: Colors.white,
child: PaymentList(
paymentsStream: paymentsStream(),
onRefresh: () async => await _sync(),
),
),
],
);
},
),
drawer: HomePageDrawer(liquidSDK: widget.liquidSDK, credentialsManager: widget.credentialsManager),
floatingActionButton: QrActionButton(liquidSDK: widget.liquidSDK),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
bottomNavigationBar: HomePageBottomAppBar(
liquidSDK: widget.liquidSDK,
paymentsStream: paymentsStream(),
),
),
);
}
Future<void> _sync() async {
try {
debugPrint("Syncing wallet.");
await widget.liquidSDK.sync();
debugPrint("Wallet synced!");
} on Exception catch (e) {
final errMsg = "Failed to sync wallet. $e";
debugPrint(errMsg);
if (context.mounted) {
final snackBar = SnackBar(content: Text(errMsg));
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
}
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_breez_liquid/flutter_breez_liquid.dart';
class Balance extends StatelessWidget {
final Stream<GetInfoResponse> walletInfoStream;
const Balance({super.key, required this.walletInfoStream});
@override
Widget build(BuildContext context) {
return StreamBuilder<GetInfoResponse>(
stream: walletInfoStream,
builder: (context, walletInfoSnapshot) {
if (walletInfoSnapshot.hasError) {
return Center(child: Text('Error: ${walletInfoSnapshot.error}'));
}
if (!walletInfoSnapshot.hasData) {
return const Center(child: Text('Loading...'));
}
if (walletInfoSnapshot.requireData.balanceSat.isNaN) {
return const Center(child: Text('No balance.'));
}
final walletInfo = walletInfoSnapshot.data!;
return Center(
child: Text(
"${walletInfo.balanceSat} sats",
style: Theme.of(context).textTheme.headlineLarge?.copyWith(color: Colors.blue),
),
);
},
);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_breez_liquid/flutter_breez_liquid.dart';
import 'package:flutter_breez_liquid_example/routes/home/widgets/receive_payment/receive_payment_dialog.dart';
import 'package:flutter_breez_liquid_example/routes/home/widgets/send_payment/send_payment_dialog.dart';
class HomePageBottomAppBar extends StatelessWidget {
final BindingLiquidSdk liquidSDK;
final Stream<List<Payment>> paymentsStream;
const HomePageBottomAppBar({super.key, required this.liquidSDK, required this.paymentsStream});
@override
Widget build(BuildContext context) {
return BottomAppBar(
color: Colors.blue,
height: 60,
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
foregroundColor: Colors.white,
),
onPressed: () {
showDialog(
context: context,
builder: (context) => SendPaymentDialog(liquidSdk: liquidSDK),
);
},
child: Text(
"SEND",
textAlign: TextAlign.center,
style: Theme.of(context).primaryTextTheme.titleMedium,
maxLines: 1,
),
),
),
Container(width: 64),
Expanded(
child: TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
foregroundColor: Colors.white,
),
onPressed: () {
showDialog(
context: context,
builder: (context) =>
ReceivePaymentDialog(liquidSDK: liquidSDK, paymentsStream: paymentsStream),
);
},
child: Text(
"RECEIVE",
textAlign: TextAlign.center,
maxLines: 1,
style: Theme.of(context).primaryTextTheme.titleMedium,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'package:flutter_breez_liquid/flutter_breez_liquid.dart';
import 'package:flutter_breez_liquid_example/routes/home/widgets/mnemonics_dialog.dart';
import 'package:flutter_breez_liquid_example/services/credentials_manager.dart';
class HomePageDrawer extends StatefulWidget {
final CredentialsManager credentialsManager;
final BindingLiquidSdk liquidSDK;
const HomePageDrawer({super.key, required this.liquidSDK, required this.credentialsManager});
@override
State<HomePageDrawer> createState() => _HomePageDrawerState();
}
class _HomePageDrawerState extends State<HomePageDrawer> {
@override
Widget build(BuildContext context) {
return Drawer(
backgroundColor: Colors.blue,
child: ListView(
padding: EdgeInsets.zero,
children: [
const DrawerHeader(
curve: Curves.fastOutSlowIn,
child: Text(
'Breez Liquid SDK Demo',
style: TextStyle(fontSize: 16.0, color: Colors.white),
),
),
ListTile(
enabled: false,
leading: const Icon(Icons.backup_outlined),
title: const Text('Backup'),
titleTextStyle:
const TextStyle(fontSize: 16.0, color: Colors.white, decoration: TextDecoration.lineThrough),
onTap: () async {
try {
debugPrint("Creating backup.");
// TODO: Backup API should return backup file or it's filepath
await widget.liquidSDK.backup();
debugPrint("Created backup.");
} catch (e) {
final errMsg = "Failed to create backup. $e";
debugPrint(errMsg);
if (context.mounted) {
final snackBar = SnackBar(content: Text(errMsg));
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
}
},
),
ListTile(
enabled: false,
leading: const Icon(Icons.restore),
title: const Text('Restore'),
titleTextStyle:
const TextStyle(fontSize: 16.0, color: Colors.white, decoration: TextDecoration.lineThrough),
onTap: () async {
try {
debugPrint("Restoring backup.");
// TODO: Select backup file to restore
RestoreRequest req = const RestoreRequest();
await widget.liquidSDK.restore(req: req);
debugPrint("Restored backup.");
} catch (e) {
final errMsg = "Failed to restore backup. $e";
debugPrint(errMsg);
if (context.mounted) {
final snackBar = SnackBar(content: Text(errMsg));
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
}
},
),
ListTile(
leading: const Icon(Icons.cached, color: Colors.white),
title: const Text('Empty Wallet Cache'),
titleTextStyle: const TextStyle(fontSize: 16.0, color: Colors.white),
onTap: () async {
try {
debugPrint("Emptying wallet cache.");
await widget.liquidSDK.emptyWalletCache();
debugPrint("Emptied wallet cache.");
} catch (e) {
final errMsg = "Failed to empty wallet cache. $e";
debugPrint(errMsg);
if (context.mounted) {
final snackBar = SnackBar(content: Text(errMsg));
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
}
},
),
ListTile(
leading: const Icon(Icons.info_outline, color: Colors.white),
title: const Text('Display Mnemonics'),
titleTextStyle: const TextStyle(fontSize: 16.0, color: Colors.white),
onTap: () async {
try {
await widget.credentialsManager.restoreMnemonic().then((mnemonics) {
showDialog(
context: context,
builder: (context) => MnemonicsDialog(
mnemonics: mnemonics.split(" "),
),
);
});
} on Exception catch (e) {
final errMsg = "Failed to display mnemonics. $e";
debugPrint(errMsg);
if (context.mounted) {
final snackBar = SnackBar(content: Text(errMsg));
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
}
},
)
],
),
);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class MnemonicsDialog extends StatelessWidget {
final List<String> mnemonics;
MnemonicsDialog({super.key, required this.mnemonics});
final textFieldControllers = List<TextEditingController>.generate(12, (_) => TextEditingController());
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text("Mnemonics"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
GridView.builder(
shrinkWrap: true,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: MediaQuery.of(context).size.width / 2,
childAspectRatio: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: mnemonics.length,
itemBuilder: (BuildContext context, int index) {
textFieldControllers[index].text = mnemonics.elementAt(index);
return TextField(
readOnly: true,
controller: textFieldControllers[index],
decoration: InputDecoration(labelText: "${index + 1}", border: InputBorder.none),
inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r"\s\b|\b\s"))],
);
},
)
],
),
actions: [
TextButton(
child: const Text("CLOSE"),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter_breez_liquid/flutter_breez_liquid.dart';
import 'package:flutter_breez_liquid_example/routes/home/widgets/payment_list/widgets/payment_item.dart';
class PaymentList extends StatelessWidget {
final Future Function() onRefresh;
final Stream<List<Payment>> paymentsStream;
const PaymentList({super.key, required this.paymentsStream, required this.onRefresh});
@override
Widget build(BuildContext context) {
return StreamBuilder<List<Payment>>(
stream: paymentsStream,
builder: (context, paymentsSnapshot) {
if (paymentsSnapshot.hasError) {
return Text('Error: ${paymentsSnapshot.error}');
}
if (!paymentsSnapshot.hasData) {
return const Center(child: Text('Loading...'));
}
if (paymentsSnapshot.requireData.isEmpty) {
return Center(
child: Text(
'You are ready to receive funds.',
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
final paymentList = paymentsSnapshot.data!;
return RefreshIndicator(
onRefresh: () async {
debugPrint("Pulled to refresh");
return await onRefresh();
},
child: ListView.builder(
itemCount: paymentList.length,
shrinkWrap: true,
primary: true,
itemBuilder: (BuildContext context, int index) => PaymentItem(item: paymentList[index]),
),
);
},
);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_breez_liquid/flutter_breez_liquid.dart';
import 'package:intl/intl.dart';
class PaymentItem extends StatelessWidget {
final Payment item;
const PaymentItem({super.key, required this.item});
@override
Widget build(BuildContext context) {
return ListTile(
onLongPress: item.preimage != null
? () {
try {
debugPrint("Store payment preimage on clipboard. Preimage: ${item.preimage!}");
Clipboard.setData(ClipboardData(text: item.preimage!));
const snackBar = SnackBar(content: Text('Copied payment preimage to clipboard.'));
ScaffoldMessenger.of(context).showSnackBar(snackBar);
} catch (e) {
final snackBar = SnackBar(
content: Text('Failed to copy payment preimage to clipboard. $e'),
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
}
: item.swapId != null
? () {
try {
debugPrint("Store swap ID on clipboard. Swap ID: ${item.swapId!}");
Clipboard.setData(ClipboardData(text: item.swapId!));
const snackBar = SnackBar(content: Text('Copied swap ID to clipboard.'));
ScaffoldMessenger.of(context).showSnackBar(snackBar);
} catch (e) {
final snackBar = SnackBar(
content: Text('Failed to copy payment preimage to clipboard. $e'),
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
}
: null,
title: Text(_paymentTitle(item)),
subtitle: Text(
DateFormat('dd/MM/yyyy, HH:mm').format(
DateTime.fromMillisecondsSinceEpoch(item.timestamp * 1000),
),
style: Theme.of(context).textTheme.bodySmall,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"${item.paymentType == PaymentType.send ? "-" : "+"}${item.amountSat} sats",
style: Theme.of(context).textTheme.bodyLarge,
),
if (item.feesSat != null) ...[
Text("FEE: ${item.feesSat} sats"),
]
],
),
);
}
String _paymentTitle(Payment payment) {
final paymentType = payment.paymentType;
switch (paymentType) {
case PaymentType.receive:
return "Received Payment";
case PaymentType.send:
return "Sent Payment";
}
}
}

View File

@@ -0,0 +1,114 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_breez_liquid_example/routes/home/widgets/qr_scan/scan_overlay.dart';
import 'package:flutter_breez_liquid_example/routes/home/widgets/qr_scan/scanner_error_widget.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class BarcodeScanner extends StatefulWidget {
const BarcodeScanner({super.key});
@override
State<BarcodeScanner> createState() => _BarcodeScannerState();
}
class _BarcodeScannerState extends State<BarcodeScanner> with WidgetsBindingObserver {
bool popped = false;
final MobileScannerController controller = MobileScannerController(
autoStart: false,
torchEnabled: false,
useNewCameraSelector: true,
);
Barcode? _barcode;
StreamSubscription<Object?>? _subscription;
void _handleBarcode(BarcodeCapture barcodes) {
if (mounted) {
setState(() {
_barcode = barcodes.barcodes.firstOrNull;
});
if (popped) {
debugPrint("Skipping, already popped");
return;
}
popped = true;
final code = _barcode?.rawValue;
if (code == null) {
debugPrint("Failed to scan QR code.");
} else {
popped = true;
debugPrint("Popping read QR code: $code");
Navigator.of(context).pop(code);
}
}
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_subscription = controller.barcodes.listen(_handleBarcode);
unawaited(controller.start());
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (!controller.value.isInitialized) {
return;
}
switch (state) {
case AppLifecycleState.detached:
case AppLifecycleState.hidden:
case AppLifecycleState.paused:
return;
case AppLifecycleState.resumed:
_subscription = controller.barcodes.listen(_handleBarcode);
unawaited(controller.start());
case AppLifecycleState.inactive:
unawaited(_subscription?.cancel());
_subscription = null;
unawaited(controller.stop());
}
}
@override
Widget build(BuildContext context) {
var scanWindowDimension = MediaQuery.of(context).size.width - 72;
return Scaffold(
body: Stack(
children: [
MobileScanner(
scanWindow: Rect.fromCenter(
center: MediaQuery.sizeOf(context).center(Offset.zero),
width: scanWindowDimension,
height: scanWindowDimension,
),
controller: controller,
errorBuilder: (context, error, child) {
return ScannerErrorWidget(error: error);
},
overlayBuilder: (context, constraints) {
return const ScanOverlay();
},
fit: BoxFit.cover,
),
],
),
);
}
@override
Future<void> dispose() async {
WidgetsBinding.instance.removeObserver(this);
unawaited(_subscription?.cancel());
_subscription = null;
super.dispose();
await controller.dispose();
}
}

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
class ScanOverlay extends StatelessWidget {
const ScanOverlay({
super.key,
});
@override
Widget build(BuildContext context) {
var dimension = MediaQuery.of(context).size.width - 72;
return Center(
child: CustomPaint(
painter: BorderPainter(),
child: SizedBox(
width: dimension,
height: dimension,
),
),
);
}
}
class BorderPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
const width = 4.0;
const radius = 16.0;
const tRadius = 2 * radius;
final rect = Rect.fromLTWH(
width,
width,
size.width - 2 * width,
size.height - 2 * width,
);
final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(radius));
const clippingRect0 = Rect.fromLTWH(
0,
0,
tRadius,
tRadius,
);
final clippingRect1 = Rect.fromLTWH(
size.width - tRadius,
0,
tRadius,
tRadius,
);
final clippingRect2 = Rect.fromLTWH(
0,
size.height - tRadius,
tRadius,
tRadius,
);
final clippingRect3 = Rect.fromLTWH(
size.width - tRadius,
size.height - tRadius,
tRadius,
tRadius,
);
final path = Path()
..addRect(clippingRect0)
..addRect(clippingRect1)
..addRect(clippingRect2)
..addRect(clippingRect3);
canvas.clipPath(path);
canvas.drawRRect(
rrect,
Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = width,
);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return false;
}
}

View File

@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class StartStopMobileScannerButton extends StatelessWidget {
const StartStopMobileScannerButton({required this.controller, super.key});
final MobileScannerController controller;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: controller,
builder: (context, state, child) {
if (!state.isInitialized || !state.isRunning) {
return IconButton(
color: Colors.white,
icon: const Icon(Icons.play_arrow),
iconSize: 32.0,
onPressed: () async {
await controller.start();
},
);
}
return IconButton(
color: Colors.white,
icon: const Icon(Icons.stop),
iconSize: 32.0,
onPressed: () async {
await controller.stop();
},
);
},
);
}
}
class SwitchCameraButton extends StatelessWidget {
const SwitchCameraButton({required this.controller, super.key});
final MobileScannerController controller;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: controller,
builder: (context, state, child) {
if (!state.isInitialized || !state.isRunning) {
return const SizedBox.shrink();
}
final int? availableCameras = state.availableCameras;
if (availableCameras != null && availableCameras < 2) {
return const SizedBox.shrink();
}
final Widget icon;
switch (state.cameraDirection) {
case CameraFacing.front:
icon = const Icon(Icons.camera_front);
case CameraFacing.back:
icon = const Icon(Icons.camera_rear);
}
return IconButton(
iconSize: 32.0,
icon: icon,
onPressed: () async {
await controller.switchCamera();
},
);
},
);
}
}
class ToggleFlashlightButton extends StatelessWidget {
const ToggleFlashlightButton({required this.controller, super.key});
final MobileScannerController controller;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: controller,
builder: (context, state, child) {
if (!state.isInitialized || !state.isRunning) {
return const SizedBox.shrink();
}
switch (state.torchState) {
case TorchState.auto:
return IconButton(
color: Colors.white,
iconSize: 32.0,
icon: const Icon(Icons.flash_auto),
onPressed: () async {
await controller.toggleTorch();
},
);
case TorchState.off:
return IconButton(
color: Colors.white,
iconSize: 32.0,
icon: const Icon(Icons.flash_off),
onPressed: () async {
await controller.toggleTorch();
},
);
case TorchState.on:
return IconButton(
color: Colors.white,
iconSize: 32.0,
icon: const Icon(Icons.flash_on),
onPressed: () async {
await controller.toggleTorch();
},
);
case TorchState.unavailable:
return const Icon(
Icons.no_flash,
color: Colors.grey,
);
}
},
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class ScannerErrorWidget extends StatelessWidget {
const ScannerErrorWidget({super.key, required this.error});
final MobileScannerException error;
@override
Widget build(BuildContext context) {
String errorMessage;
switch (error.errorCode) {
case MobileScannerErrorCode.controllerUninitialized:
errorMessage = 'Controller not ready.';
case MobileScannerErrorCode.permissionDenied:
errorMessage = 'Permission denied';
case MobileScannerErrorCode.unsupported:
errorMessage = 'Scanning is unsupported on this device';
default:
errorMessage = 'Generic Error';
break;
}
return ColoredBox(
color: Colors.black,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.only(bottom: 16),
child: Icon(Icons.error, color: Colors.white),
),
Text(
errorMessage,
style: const TextStyle(color: Colors.white),
),
Text(
error.errorDetails?.message ?? '',
style: const TextStyle(color: Colors.white),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:flutter_breez_liquid/flutter_breez_liquid.dart';
import 'package:flutter_breez_liquid_example/routes/home/widgets/qr_scan/barcode_scanner_simple.dart';
import 'package:flutter_breez_liquid_example/routes/home/widgets/send_payment/send_payment_dialog.dart';
class QrActionButton extends StatelessWidget {
final BindingLiquidSdk liquidSDK;
const QrActionButton({super.key, required this.liquidSDK});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 32.0),
child: FloatingActionButton(
backgroundColor: Colors.white,
shape: const StadiumBorder(),
onPressed: () => _scanBarcode(context),
child: const Icon(
Icons.qr_code_2,
size: 32,
color: Colors.blue,
),
),
);
}
void _scanBarcode(BuildContext context) {
debugPrint("Scanning for QR Code");
Navigator.of(context).push<String?>(
MaterialPageRoute(builder: (context) {
return const BarcodeScanner();
}),
).then((barcode) {
if (barcode == null || barcode.isEmpty) return;
debugPrint("Scanned string: '$barcode'");
showDialog(
context: context,
builder: (context) => SendPaymentDialog(barcodeValue: barcode, liquidSdk: liquidSDK),
);
});
}
}

View File

@@ -0,0 +1,170 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_breez_liquid/flutter_breez_liquid.dart';
import 'package:qr_flutter/qr_flutter.dart';
class ReceivePaymentDialog extends StatefulWidget {
final BindingLiquidSdk liquidSDK;
final Stream<List<Payment>> paymentsStream;
const ReceivePaymentDialog({super.key, required this.liquidSDK, required this.paymentsStream});
@override
State<ReceivePaymentDialog> createState() => _ReceivePaymentDialogState();
}
class _ReceivePaymentDialogState extends State<ReceivePaymentDialog> {
final TextEditingController payerAmountController = TextEditingController();
int? payerAmountSat;
int? feesSat;
bool creatingInvoice = false;
String? invoice;
String? invoiceId;
StreamSubscription<List<Payment>>? streamSubscription;
@override
void initState() {
super.initState();
streamSubscription = widget.paymentsStream.listen((paymentList) {
if (invoiceId != null && invoiceId!.isNotEmpty) {
if (paymentList.any((e) => e.swapId == invoiceId!)) {
debugPrint("Payment Received! Id: $invoiceId");
if (context.mounted) {
Navigator.of(context).pop();
}
}
}
});
}
@override
void dispose() {
streamSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: creatingInvoice ? null : Text(invoice != null ? "Invoice" : "Receive Payment"),
content: creatingInvoice || invoice != null
? Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (invoice != null) ...[
AspectRatio(
aspectRatio: 1,
child: QrImageView(
embeddedImage: const AssetImage("assets/icons/app_icon.png"),
data: invoice!.toUpperCase(),
size: 200.0,
),
),
if (payerAmountSat != null && feesSat != null) ...[
Padding(
padding: const EdgeInsets.only(left: 8.0, right: 8.0),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
const Text('Payer Amount:'),
const Expanded(child: SizedBox(width: 0)),
Text('$payerAmountSat sats'),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 8.0, right: 8.0),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
const Text('Payer Fees:'),
const Expanded(child: SizedBox(width: 0)),
Text('$feesSat sats'),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 8.0, right: 8.0),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
const Text('Receive Amount:'),
const Expanded(child: SizedBox(width: 0)),
Text('${payerAmountSat! - feesSat!} sats'),
],
),
),
]
],
if (creatingInvoice) ...[
const Text('Creating Invoice...'),
const SizedBox(height: 16),
const CircularProgressIndicator(color: Colors.blue),
]
],
)
: TextField(
controller: payerAmountController,
decoration: const InputDecoration(label: Text("Enter payer amount in sats")),
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
keyboardType: TextInputType.number,
),
actions: creatingInvoice
? []
: [
TextButton(
child: const Text("CANCEL"),
onPressed: () {
Navigator.of(context).pop();
},
),
if (invoice == null) ...[
TextButton(
onPressed: () async {
try {
setState(() => creatingInvoice = true);
int amountSat = int.parse(payerAmountController.text);
PrepareReceiveRequest prepareReceiveReq =
PrepareReceiveRequest(payerAmountSat: amountSat);
PrepareReceiveResponse req =
await widget.liquidSDK.prepareReceivePayment(req: prepareReceiveReq);
setState(() {
payerAmountSat = req.payerAmountSat;
feesSat = req.feesSat;
});
ReceivePaymentResponse resp = await widget.liquidSDK.receivePayment(req: req);
debugPrint(
"Created Invoice for $payerAmountSat sats with $feesSat sats fees.\nInvoice:${resp.invoice}",
);
setState(() => invoice = resp.invoice);
setState(() => invoiceId = resp.id);
} catch (e) {
setState(() {
payerAmountSat = null;
feesSat = null;
invoice = null;
invoiceId = null;
});
final errMsg = "Error receiving payment: $e";
debugPrint(errMsg);
if (context.mounted) {
final snackBar = SnackBar(content: Text(errMsg));
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
} finally {
setState(() => creatingInvoice = false);
}
},
child: const Text("OK"),
),
]
],
);
}
}

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_breez_liquid/flutter_breez_liquid.dart';
class SendPaymentDialog extends StatefulWidget {
final String? barcodeValue;
final BindingLiquidSdk liquidSdk;
const SendPaymentDialog({super.key, required this.liquidSdk, this.barcodeValue});
@override
State<SendPaymentDialog> createState() => _SendPaymentDialogState();
}
class _SendPaymentDialogState extends State<SendPaymentDialog> {
final TextEditingController invoiceController = TextEditingController();
bool paymentInProgress = false;
PrepareSendResponse? sendPaymentReq;
@override
void initState() {
super.initState();
if (widget.barcodeValue != null) {
invoiceController.text = widget.barcodeValue!;
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: paymentInProgress ? null : const Text("Send Payment"),
content: paymentInProgress
? Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('${sendPaymentReq == null ? "Preparing" : "Sending"} payment...'),
const SizedBox(height: 16),
const CircularProgressIndicator(color: Colors.blue),
],
),
)
: sendPaymentReq != null
? Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Please confirm that you agree to the payment fee of ${sendPaymentReq!.feesSat} sats.',
),
],
),
)
: TextField(
decoration: InputDecoration(
label: const Text("Enter Invoice"),
suffixIcon: IconButton(
icon: const Icon(Icons.paste, color: Colors.blue),
onPressed: () async {
final clipboardData = await Clipboard.getData('text/plain');
if (clipboardData != null && clipboardData.text != null) {
invoiceController.text = clipboardData.text!;
}
},
),
),
controller: invoiceController,
),
actions: paymentInProgress
? []
: [
TextButton(
child: const Text("CANCEL"),
onPressed: () {
Navigator.of(context).pop();
},
),
sendPaymentReq == null
? TextButton(
onPressed: () async {
try {
setState(() => paymentInProgress = true);
PrepareSendRequest prepareSendReq =
PrepareSendRequest(invoice: invoiceController.text);
PrepareSendResponse req =
await widget.liquidSdk.prepareSendPayment(req: prepareSendReq);
debugPrint("PrepareSendResponse for ${req.invoice}, fees: ${req.feesSat}");
setState(() => sendPaymentReq = req);
} catch (e) {
final errMsg = "Error preparing payment: $e";
debugPrint(errMsg);
if (context.mounted) {
Navigator.pop(context);
final snackBar = SnackBar(content: Text(errMsg));
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
} finally {
setState(() => paymentInProgress = false);
}
},
child: const Text("OK"),
)
: TextButton(
onPressed: () async {
try {
setState(() => paymentInProgress = true);
SendPaymentResponse resp = await widget.liquidSdk.sendPayment(req: sendPaymentReq!);
debugPrint("Paid ${resp.txid}");
if (context.mounted) {
Navigator.pop(context);
}
} catch (e) {
final errMsg = "Error sending payment: $e";
debugPrint(errMsg);
if (context.mounted) {
Navigator.pop(context);
final snackBar = SnackBar(content: Text(errMsg));
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
} finally {
setState(() => paymentInProgress = false);
}
},
child: const Text("CONFIRM"),
),
],
);
}
}