From 31ceeb0c7fb225c94c127860a1df2a454dd226b3 Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Wed, 14 Feb 2024 12:53:19 +0100 Subject: [PATCH 1/2] feat: brag the bag Share your current pnl from your running position as an image. --- CHANGELOG.md | 1 + mobile/assets/10101-qr-transparent.svg | 17 + mobile/assets/10101_logo.svg | 17 + mobile/assets/10101_logo_white.svg | 16 + mobile/lib/common/domain/model.dart | 1 + mobile/lib/common/init_service.dart | 5 +- mobile/lib/features/brag/brag.dart | 426 ++++++++++++++++++ mobile/lib/features/brag/github_service.dart | 33 ++ .../lib/features/trade/domain/leverage.dart | 2 + .../features/trade/position_list_item.dart | 49 +- mobile/pubspec.lock | 16 + mobile/pubspec.yaml | 2 + 12 files changed, 581 insertions(+), 4 deletions(-) create mode 100644 mobile/assets/10101-qr-transparent.svg create mode 100644 mobile/assets/10101_logo.svg create mode 100644 mobile/assets/10101_logo_white.svg create mode 100644 mobile/lib/features/brag/brag.dart create mode 100644 mobile/lib/features/brag/github_service.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index da8261c28..307aac3bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Feat(webapp): Let user manually sync wallet history. - Feat(webapp): Allow to close or force-close a channel. - Feat(app): Count pending but trusted utxos to wallet balance. +- Feat(mobile): Brag the bag: share your PnL as an image ## [1.8.11] - 2024-02-20 diff --git a/mobile/assets/10101-qr-transparent.svg b/mobile/assets/10101-qr-transparent.svg new file mode 100644 index 000000000..afba7fbae --- /dev/null +++ b/mobile/assets/10101-qr-transparent.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/mobile/assets/10101_logo.svg b/mobile/assets/10101_logo.svg new file mode 100644 index 000000000..956cb263c --- /dev/null +++ b/mobile/assets/10101_logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/mobile/assets/10101_logo_white.svg b/mobile/assets/10101_logo_white.svg new file mode 100644 index 000000000..52a40d2df --- /dev/null +++ b/mobile/assets/10101_logo_white.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/mobile/lib/common/domain/model.dart b/mobile/lib/common/domain/model.dart index 9b98e6378..7803b6c55 100644 --- a/mobile/lib/common/domain/model.dart +++ b/mobile/lib/common/domain/model.dart @@ -80,6 +80,7 @@ class Usd { double asDouble() => _usd.toDouble(); + Usd.fromDouble(double value) : _usd = Decimal.parse(value.toString()); Usd.parse(dynamic value) : _usd = Decimal.parse(value); Usd.parseString(String? value) { diff --git a/mobile/lib/common/init_service.dart b/mobile/lib/common/init_service.dart index afa482bf9..04f509857 100644 --- a/mobile/lib/common/init_service.dart +++ b/mobile/lib/common/init_service.dart @@ -3,6 +3,7 @@ import 'package:get_10101/common/application/lsp_change_notifier.dart'; import 'package:get_10101/common/dlc_channel_change_notifier.dart'; import 'package:get_10101/common/dlc_channel_service.dart'; import 'package:get_10101/common/domain/lsp_config.dart'; +import 'package:get_10101/features/brag/github_service.dart'; import 'package:get_10101/features/trade/candlestick_change_notifier.dart'; import 'package:get_10101/features/trade/order_change_notifier.dart'; import 'package:get_10101/features/trade/position_change_notifier.dart'; @@ -45,6 +46,7 @@ List createProviders() { const ChannelInfoService channelInfoService = ChannelInfoService(); var tradeValuesService = TradeValuesService(); const pollService = PollService(); + const githubService = GitHubService(); var providers = [ ChangeNotifierProvider(create: (context) { @@ -70,7 +72,8 @@ List createProviders() { ChangeNotifierProvider(create: (context) => PollChangeNotifier(pollService)), Provider(create: (context) => config), Provider(create: (context) => channelInfoService), - Provider(create: (context) => pollService) + Provider(create: (context) => pollService), + Provider(create: (context) => githubService) ]; if (config.network == "regtest") { providers.add(Provider(create: (context) => FaucetService())); diff --git a/mobile/lib/features/brag/brag.dart b/mobile/lib/features/brag/brag.dart new file mode 100644 index 000000000..38e2189a4 --- /dev/null +++ b/mobile/lib/features/brag/brag.dart @@ -0,0 +1,426 @@ +import 'dart:io'; + +import 'package:bitcoin_icons/bitcoin_icons.dart'; +import 'package:card_swiper/card_swiper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get_10101/common/color.dart'; +import 'package:get_10101/common/domain/model.dart'; +import 'package:get_10101/features/brag/github_service.dart'; +import 'package:get_10101/features/trade/domain/direction.dart'; +import 'package:get_10101/features/trade/domain/leverage.dart'; +import 'package:get_10101/logger/logger.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:screenshot/screenshot.dart'; +import 'package:provider/provider.dart'; +import 'package:share_plus/share_plus.dart'; + +class BragWidget extends StatefulWidget { + final String title; + final VoidCallback onClose; + final Direction direction; + final Leverage leverage; + final Amount? pnl; + final int? pnlPercent; + final Usd entryPrice; + + const BragWidget( + {super.key, + required this.title, + required this.onClose, + required this.direction, + required this.leverage, + required this.pnl, + required this.entryPrice, + this.pnlPercent}); + + @override + State createState() => _BragWidgetState(); +} + +class _BragWidgetState extends State { + ScreenshotController screenShotController = ScreenshotController(); + int selectedIndex = 0; + var images = [ + "https://github.com/bonomat/memes/blob/main/images/laser_eyes_portrait.png?raw=true", + "https://github.com/bonomat/memes/blob/main/images/leoardo_cheers_portrait.png?raw=true", + "https://github.com/bonomat/memes/blob/main/images/do_something_portrait.png?raw=true", + "https://github.com/bonomat/memes/blob/main/images/got_some_sats_portrait.png?raw=true", + "https://github.com/bonomat/memes/blob/main/images/are_you_winning_son_always_have_been_portrait.png?raw=true" + ]; + + @override + Widget build(BuildContext context) { + final githubService = context.read(); + double height = 337.5 * 0.9 + 30; + double width = 270.0 * 0.9 + 30; + return AlertDialog( + title: Text(widget.title), + content: SizedBox( + height: height, + width: width, + child: Column( + children: [ + SizedBox( + width: width - 30, + height: height - 30, + child: Screenshot( + controller: screenShotController, + child: FutureBuilder( + future: githubService.fetchMemeImages(), + builder: (BuildContext context, AsyncSnapshot> snapshot) { + if (!snapshot.hasData) { + return const SizedBox( + width: 50, height: 50, child: Center(child: CircularProgressIndicator())); + } else { + return MemeWidget( + images: snapshot.data!.map((item) => item.downloadUrl).toList(), + pnl: widget.pnl ?? Amount.zero(), + leverage: widget.leverage, + direction: widget.direction, + entryPrice: widget.entryPrice, + onIndexChange: (index) { + setState(() { + selectedIndex = index; + }); + }, + pnlPercent: widget.pnlPercent ?? 0, + ); + } + }, + ), + ), + ), + const SizedBox( + height: 10, + ), + Container( + color: Colors.transparent, + child: SizedBox( + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: images.asMap().entries.map((entry) { + final index = entry.key; + return Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0), + child: Container( + decoration: BoxDecoration( + color: index == selectedIndex ? tenTenOnePurple : Colors.white, + borderRadius: BorderRadius.circular(50), + border: Border.all(color: tenTenOnePurple, width: 1), + ), + width: 10, + height: 10, + ), + ); + }).toList(), + ), + ), + ) + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, 'Cancel'), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + await screenShotController + .capture(delay: const Duration(milliseconds: 10)) + .then((image) async { + logger.i("taking foto"); + if (image != null) { + final directory = await getApplicationDocumentsDirectory(); + final imagePath = await File('${directory.path}/image.png').create(); + await imagePath.writeAsBytes(image); + await Share.shareXFiles( + [XFile(imagePath.path, mimeType: "image/x-png", bytes: image)]); + } + }).catchError((error) { + logger.e("Failed at capturing screenshot", error: error); + }).whenComplete(widget.onClose); + }, + child: const Text('Share'), + ), + ], + ); + } +} + +class MemeWidget extends StatelessWidget { + const MemeWidget({ + super.key, + required this.images, + required this.pnl, + required this.leverage, + required this.direction, + required this.entryPrice, + required this.onIndexChange, + required this.pnlPercent, + }); + + final List images; + final Amount pnl; + final int pnlPercent; + final Leverage leverage; + final Direction direction; + final Usd entryPrice; + final Function onIndexChange; + + @override + Widget build(BuildContext context) { + bool losing = pnl.sats.isNegative; + + const gradientColor0 = tenTenOnePurple; + var gradient1 = const Color.fromRGBO(0, 250, 130, 0.20); + var gradient2 = const Color.fromRGBO(0, 250, 130, 0.1); + var pnlColor = Colors.green.shade200; + + if (losing) { + gradient1 = const Color.fromRGBO(250, 99, 99, 0.30); + gradient2 = const Color.fromRGBO(250, 99, 99, 0.1); + pnlColor = Colors.red.shade400; + } + + const secondaryTextHeading = TextStyle( + color: Colors.white, + fontSize: 8.0, + decoration: TextDecoration.underline, + ); + var secondaryTextValue = + TextStyle(color: pnlColor, fontWeight: FontWeight.bold, fontSize: 15.0); + var primaryTextStypeValue = TextStyle( + color: pnlColor, + fontWeight: FontWeight.bold, + fontSize: 30, + ); + + return Swiper( + itemCount: images.length, + pagination: null, + control: const SwiperControl(color: Colors.transparent), + onIndexChanged: (index) { + onIndexChange(index); + }, + itemBuilder: (BuildContext context, int index) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5.0), + border: Border.all(color: Colors.grey, width: 1)), + child: Stack( + children: [ + Column( + children: [ + Expanded( + child: Row( + children: [ + Expanded( + flex: 3, + child: Container( + color: Colors.transparent, + child: Image.network( + images[index], + fit: BoxFit.fill, + ), + ), + ), + ], + ), + ), + ], + ), + Column( + children: [ + Expanded( + flex: 3, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + gradient1, + gradient2, + ], + ), + )), + ), + Expanded( + flex: 2, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + gradientColor0, + gradient1, + ], + ), + )), + ), + Expanded( + flex: 2, + child: Container( + color: tenTenOnePurple, + )), + ], + ), + Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 55, + height: 55, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all( + color: tenTenOnePurple, + ), + borderRadius: const BorderRadius.all(Radius.circular(5))), + child: Padding( + padding: const EdgeInsets.all(2.0), + child: SvgPicture.asset( + 'assets/10101_logo.svg', + )), + )), + )), + Align( + alignment: Alignment.topRight, + child: SizedBox( + width: 55, + height: 55, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all( + color: tenTenOnePurple, + ), + borderRadius: const BorderRadius.all(Radius.circular(5))), + child: Padding( + padding: const EdgeInsets.all(2.0), + child: SvgPicture.asset('assets/10101-qr-transparent.svg', + colorFilter: + const ColorFilter.mode(Colors.black, BlendMode.srcIn)), + )), + )), + ), + Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + children: [ + Text( + pnl.formatted(), + style: primaryTextStypeValue, + ), + Icon( + BitcoinIcons.satoshi_v2, + color: pnlColor, + size: 20, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + children: [ + Text( + pnlPercent.toString(), + style: primaryTextStypeValue, + ), + Icon( + Icons.percent, + color: pnlColor, + size: 20, + ), + ], + ), + ) + ], + ), + ), + const SizedBox( + height: 10, + ), + Padding( + padding: const EdgeInsets.only(left: 8, bottom: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + const Text( + "Side", + style: secondaryTextHeading, + ), + const SizedBox( + width: 5, + ), + Text( + "${leverage.formattedReverse()} ${direction.nameU}", + style: secondaryTextValue, + ) + ], + ), + Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + children: [ + const Text( + "Entry price", + style: secondaryTextHeading, + ), + const SizedBox( + width: 5, + ), + Text( + entryPrice.toString(), + style: secondaryTextValue, + ) + ], + ), + ) + ], + ), + ], + ), + ), + const Padding( + padding: EdgeInsets.only(bottom: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "I’m trading self-custodial and without counterparty risk at 10101", + style: TextStyle( + color: Colors.white, fontSize: 7, fontWeight: FontWeight.bold), + ) + ], + ), + ) + ], + ) + ], + ), + ); + }); + } +} diff --git a/mobile/lib/features/brag/github_service.dart b/mobile/lib/features/brag/github_service.dart new file mode 100644 index 000000000..0bca3a6a2 --- /dev/null +++ b/mobile/lib/features/brag/github_service.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +class GitHubService { + const GitHubService(); + + Future> fetchMemeImages() async { + final response = + await http.get(Uri.parse('https://api.github.com/repos/bonomat/memes/contents/images/')); + + if (response.statusCode == 200) { + List data = jsonDecode(response.body); + List memes = data.map((item) => Meme.fromJson(item)).toList(); + return memes; + } else { + throw Exception('Failed to load meme images'); + } + } +} + +class Meme { + final String downloadUrl; + + Meme({ + required this.downloadUrl, + }); + + factory Meme.fromJson(Map json) { + return Meme( + downloadUrl: json['download_url'], + ); + } +} diff --git a/mobile/lib/features/trade/domain/leverage.dart b/mobile/lib/features/trade/domain/leverage.dart index 523b0f927..597bf986f 100644 --- a/mobile/lib/features/trade/domain/leverage.dart +++ b/mobile/lib/features/trade/domain/leverage.dart @@ -4,6 +4,8 @@ class Leverage { Leverage(this.leverage); String formatted() => "x${leverage % 1 == 0 ? leverage.toInt().toString() : leverage.toString()}"; + String formattedReverse() => + "${leverage % 1 == 0 ? leverage.toInt().toString() : leverage.toString()}x"; @override bool operator ==(Object other) => diff --git a/mobile/lib/features/trade/position_list_item.dart b/mobile/lib/features/trade/position_list_item.dart index 44eed3707..80065d2f9 100644 --- a/mobile/lib/features/trade/position_list_item.dart +++ b/mobile/lib/features/trade/position_list_item.dart @@ -1,7 +1,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:get_10101/common/color.dart'; +import 'package:get_10101/common/domain/model.dart'; import 'package:get_10101/common/value_data_row.dart'; +import 'package:get_10101/features/brag/brag.dart'; import 'package:get_10101/features/trade/domain/direction.dart'; import 'package:get_10101/features/trade/domain/position.dart'; import 'package:get_10101/features/trade/position_change_notifier.dart'; @@ -71,11 +75,11 @@ class _PositionListItemState extends State { padding: const EdgeInsets.fromLTRB(10.0, 10, 10, 0), child: Column( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Stack( children: [ Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ const ContractSymbolIcon(), const SizedBox( @@ -92,6 +96,45 @@ class _PositionListItemState extends State { notNullPosition.direction.keySuffix, style: const TextStyle(fontWeight: FontWeight.bold), ), + const Spacer(), + ClipOval( + child: Material( + color: Colors.grey.shade100, + child: InkWell( + splashColor: tenTenOnePurple.shade200, + onTap: () { + showDialog( + context: context, + builder: (BuildContext context) { + double unrealizedPnL = notNullPosition.unrealizedPnl?.sats == null + ? 0.0 + : double.parse(notNullPosition.unrealizedPnl!.sats.toString()); + double pnlPercent = + (unrealizedPnL / notNullPosition.collateral.sats) * 100.0; + return BragWidget( + title: 'Share as image', + onClose: () { + Navigator.of(context).pop(); + }, + direction: notNullPosition.direction, + leverage: notNullPosition.leverage, + pnl: notNullPosition.unrealizedPnl, + pnlPercent: double.parse(pnlPercent.toStringAsFixed(0)).toInt(), + entryPrice: Usd.fromDouble(notNullPosition.averageEntryPrice), + ); + }, + ); + }, + child: const SizedBox( + width: 32, + height: 32, + child: Icon( + FontAwesomeIcons.shareNodes, + size: 18, + )), + ), + ), + ) ], ), ], diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 2cb018637..c4d8460f3 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -145,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + card_swiper: + dependency: "direct main" + description: + name: card_swiper + sha256: "21e52a144decbf0054e7cfed8bbe46fc89635e6c86b767eaccfe7d5aeba32528" + url: "https://pub.dev" + source: hosted + version: "3.0.1" carousel_slider: dependency: "direct main" description: @@ -904,6 +912,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.2" + screenshot: + dependency: "direct main" + description: + name: screenshot + sha256: "455284ff1f5b911d94a43c25e1385485cf6b4f288293eba68f15dad711c7b81c" + url: "https://pub.dev" + source: hosted + version: "2.1.0" share_plus: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index d8b9e5854..89c545738 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -45,6 +45,8 @@ dependencies: nested: ^1.0.0 qr_code_scanner: ^1.0.1 fl_chart: 0.64.0 + card_swiper: ^3.0.1 + screenshot: ^2.1.0 dependency_overrides: intl: ^0.18.0 dev_dependencies: From 9da5762d492e04b677f878694f033cbe0c4c67c3 Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Mon, 19 Feb 2024 12:29:38 +0100 Subject: [PATCH 2/2] feat: share from closign dialog --- mobile/lib/features/trade/trade_dialog.dart | 23 +++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/trade/trade_dialog.dart b/mobile/lib/features/trade/trade_dialog.dart index 6b64afcd2..861f07367 100644 --- a/mobile/lib/features/trade/trade_dialog.dart +++ b/mobile/lib/features/trade/trade_dialog.dart @@ -10,6 +10,7 @@ import 'package:get_10101/common/global_keys.dart'; import 'package:get_10101/common/snack_bar.dart'; import 'package:get_10101/common/task_status_dialog.dart'; import 'package:get_10101/common/value_data_row.dart'; +import 'package:get_10101/features/brag/brag.dart'; import 'package:get_10101/features/trade/domain/trade_values.dart'; import 'package:get_10101/features/trade/submit_order_change_notifier.dart'; import 'package:provider/provider.dart'; @@ -118,9 +119,27 @@ Widget createSubmitWidget( padding: const EdgeInsets.only(top: 20, left: 10, right: 10, bottom: 5), child: ElevatedButton( onPressed: () async { - await shareTweet(pendingOrder.positionAction); + showDialog( + context: context, + builder: (BuildContext context) { + double realizedPnl = double.parse(pendingOrder.pnl?.sats.toString() ?? "0"); + double margin = double.parse(pendingOrderValues?.margin?.sats.toString() ?? "0"); + double pnlPercent = (realizedPnl / margin) * 100.0; + return BragWidget( + title: 'Share as image', + onClose: () { + Navigator.of(context).pop(); + }, + direction: pendingOrderValues!.direction, + leverage: pendingOrderValues.leverage, + pnl: pendingOrder.pnl ?? Amount.zero(), + pnlPercent: double.parse(pnlPercent.toStringAsFixed(0)).toInt(), + entryPrice: Usd.fromDouble(pendingOrderValues.price ?? 0.0), + ); + }, + ); }, - child: const Text("Share on Twitter")), + child: const Text("Share as image")), )); } }