From ae4c80ee39192fea2f43ce8f09548d934516f623 Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Mon, 5 Feb 2024 15:57:59 +0100 Subject: [PATCH 1/2] feat(mobile): show in app poll --- CHANGELOG.md | 2 + .../down.sql | 5 + .../2024-02-02-075927_add_polls_table/up.sql | 37 +++ coordinator/src/db/custom_types.rs | 20 ++ coordinator/src/db/mod.rs | 1 + coordinator/src/db/polls.rs | 135 +++++++++++ coordinator/src/routes.rs | 34 +++ coordinator/src/schema.rs | 40 ++++ crates/commons/src/lib.rs | 2 + crates/commons/src/polls.rs | 49 ++++ mobile/lib/common/init_service.dart | 7 +- mobile/lib/common/poll_widget.dart | 216 ++++++++++++++++++ .../common/settings/emergency_kit_screen.dart | 36 +++ mobile/lib/features/wallet/wallet_screen.dart | 29 ++- mobile/lib/util/poll_change_notified.dart | 36 +++ mobile/lib/util/poll_service.dart | 23 ++ .../2024-02-05-120600_answered_polls/down.sql | 2 + .../2024-02-05-120600_answered_polls/up.sql | 14 ++ mobile/native/src/api.rs | 89 ++++++++ mobile/native/src/db/mod.rs | 23 ++ mobile/native/src/db/polls.rs | 50 ++++ mobile/native/src/lib.rs | 1 + mobile/native/src/polls.rs | 74 ++++++ mobile/native/src/schema.rs | 18 ++ 24 files changed, 933 insertions(+), 10 deletions(-) create mode 100644 coordinator/migrations/2024-02-02-075927_add_polls_table/down.sql create mode 100644 coordinator/migrations/2024-02-02-075927_add_polls_table/up.sql create mode 100644 coordinator/src/db/polls.rs create mode 100644 crates/commons/src/polls.rs create mode 100644 mobile/lib/common/poll_widget.dart create mode 100644 mobile/lib/util/poll_change_notified.dart create mode 100644 mobile/lib/util/poll_service.dart create mode 100644 mobile/native/migrations/2024-02-05-120600_answered_polls/down.sql create mode 100644 mobile/native/migrations/2024-02-05-120600_answered_polls/up.sql create mode 100644 mobile/native/src/db/polls.rs create mode 100644 mobile/native/src/polls.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fc1d40de5..a9a2083a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Feat(mobile): Add in-app survey feature. The coordinator can trigger surveys which will be shown in the app. + ## [1.8.5] - 2024-02-05 - Feat(webapp): Show order history diff --git a/coordinator/migrations/2024-02-02-075927_add_polls_table/down.sql b/coordinator/migrations/2024-02-02-075927_add_polls_table/down.sql new file mode 100644 index 000000000..42ddaa654 --- /dev/null +++ b/coordinator/migrations/2024-02-02-075927_add_polls_table/down.sql @@ -0,0 +1,5 @@ +-- This file should undo anything in `up.sql` +drop table answers; +drop table choices; +drop table polls; +DROP TYPE IF EXISTS "Poll_Type_Type"; diff --git a/coordinator/migrations/2024-02-02-075927_add_polls_table/up.sql b/coordinator/migrations/2024-02-02-075927_add_polls_table/up.sql new file mode 100644 index 000000000..cc3a09064 --- /dev/null +++ b/coordinator/migrations/2024-02-02-075927_add_polls_table/up.sql @@ -0,0 +1,37 @@ +-- Your SQL goes here + +CREATE TYPE "Poll_Type_Type" AS ENUM ('SingleChoice'); + +CREATE TABLE polls +( + id SERIAL PRIMARY KEY NOT NULL, + poll_type "Poll_Type_Type" NOT NULL, + question TEXT NOT NULL, + active BOOLEAN NOT NULL, + creation_timestamp timestamp WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE choices +( + id SERIAL PRIMARY KEY NOT NULL, + poll_id SERIAL REFERENCES polls (id), + value TEXT NOT NULL +); + +CREATE TABLE answers +( + id SERIAL PRIMARY KEY NOT NULL, + choice_id SERIAL REFERENCES choices (id), + trader_pubkey TEXT NOT NULL REFERENCES users (pubkey), + value TEXT NOT NULL, + creation_timestamp timestamp WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO polls (poll_type, question, active) +VALUES ('SingleChoice', 'Where did you hear about us?', true); + +INSERT INTO choices (poll_id, value) +VALUES (1, 'Social media (X.com, Nostr)'), + (1, 'Search engine (Google, Duckduckgo)'), + (1, 'Friends'), + (1, 'other'); diff --git a/coordinator/src/db/custom_types.rs b/coordinator/src/db/custom_types.rs index a943b5f17..2035eeb05 100644 --- a/coordinator/src/db/custom_types.rs +++ b/coordinator/src/db/custom_types.rs @@ -2,6 +2,7 @@ use crate::db::channels::ChannelState; use crate::db::dlc_messages::MessageType; use crate::db::payments::HtlcStatus; use crate::db::payments::PaymentFlow; +use crate::db::polls::PollType; use crate::db::positions::ContractSymbol; use crate::db::positions::PositionState; use crate::schema::sql_types::ChannelStateType; @@ -10,6 +11,7 @@ use crate::schema::sql_types::DirectionType; use crate::schema::sql_types::HtlcStatusType; use crate::schema::sql_types::MessageTypeType; use crate::schema::sql_types::PaymentFlowType; +use crate::schema::sql_types::PollTypeType; use crate::schema::sql_types::PositionStateType; use diesel::deserialize; use diesel::deserialize::FromSql; @@ -205,3 +207,21 @@ impl FromSql for MessageType { } } } + +impl ToSql for PollType { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + match *self { + PollType::SingleChoice => out.write_all(b"SingleChoice")?, + } + Ok(IsNull::No) + } +} + +impl FromSql for PollType { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + match bytes.as_bytes() { + b"SingleChoice" => Ok(PollType::SingleChoice), + _ => Err("Unrecognized enum variant for PollType".into()), + } + } +} diff --git a/coordinator/src/db/mod.rs b/coordinator/src/db/mod.rs index b6af19466..67308d4ea 100644 --- a/coordinator/src/db/mod.rs +++ b/coordinator/src/db/mod.rs @@ -7,6 +7,7 @@ pub mod legacy_collaborative_reverts; pub mod liquidity; pub mod liquidity_options; pub mod payments; +pub mod polls; pub mod positions; pub mod positions_helper; pub mod routing_fees; diff --git a/coordinator/src/db/polls.rs b/coordinator/src/db/polls.rs new file mode 100644 index 000000000..5de3ba238 --- /dev/null +++ b/coordinator/src/db/polls.rs @@ -0,0 +1,135 @@ +use crate::schema::answers; +use crate::schema::choices; +use crate::schema::polls; +use crate::schema::sql_types::PollTypeType; +use anyhow::bail; +use anyhow::Result; +use diesel::query_builder::QueryId; +use diesel::AsExpression; +use diesel::FromSqlRow; +use diesel::Identifiable; +use diesel::Insertable; +use diesel::PgConnection; +use diesel::QueryDsl; +use diesel::QueryResult; +use diesel::Queryable; +use diesel::RunQueryDsl; +use diesel::Selectable; +use diesel::SelectableHelper; +use std::any::TypeId; +use std::collections::HashMap; +use time::OffsetDateTime; + +#[derive(Debug, Clone, Copy, PartialEq, FromSqlRow, AsExpression, Eq, Hash)] +#[diesel(sql_type = PollTypeType)] +pub enum PollType { + SingleChoice, +} + +impl QueryId for PollTypeType { + type QueryId = PollTypeType; + const HAS_STATIC_QUERY_ID: bool = false; + + fn query_id() -> Option { + None + } +} + +#[derive(Insertable, Queryable, Identifiable, Selectable, Debug, Clone, Eq, PartialEq, Hash)] +#[diesel(table_name = polls)] +#[diesel(primary_key(id))] +pub struct Poll { + pub id: i32, + pub poll_type: PollType, + pub question: String, + pub active: bool, + pub creation_timestamp: OffsetDateTime, +} + +#[derive(Insertable, Queryable, Identifiable, Selectable, Debug, Clone, Eq, PartialEq)] +#[diesel(belongs_to(Poll))] +#[diesel(table_name = choices)] +#[diesel(primary_key(id))] +pub struct Choice { + pub id: i32, + pub poll_id: i32, + pub value: String, +} +#[derive(Insertable, Queryable, Identifiable, Debug, Clone)] +#[diesel(primary_key(id))] +pub struct Answer { + pub id: Option, + pub choice_id: i32, + pub trader_pubkey: String, + pub value: String, + pub creation_timestamp: OffsetDateTime, +} + +pub fn active(conn: &mut PgConnection) -> QueryResult> { + let _polls: Vec = polls::table.load(conn)?; + let _choices: Vec = choices::table.load(conn)?; + + let results = polls::table + .left_join(choices::table) + .select(<(Poll, Option)>::as_select()) + .load::<(Poll, Option)>(conn)?; + + let mut polls_with_choices = HashMap::new(); + for (poll, choice) in results { + let entry = polls_with_choices.entry(poll).or_insert_with(Vec::new); + if let Some(choice) = choice { + entry.push(choice); + } + } + + let polls = polls_with_choices + .into_iter() + .map(|(poll, choice_vec)| commons::Poll { + id: poll.id, + poll_type: poll.poll_type.into(), + question: poll.question, + choices: choice_vec + .into_iter() + .map(|choice| commons::Choice { + id: choice.id, + value: choice.value, + }) + .collect(), + }) + .collect(); + Ok(polls) +} + +impl From for commons::PollType { + fn from(value: PollType) -> Self { + match value { + PollType::SingleChoice => commons::PollType::SingleChoice, + } + } +} + +pub fn add_answer(conn: &mut PgConnection, answers: commons::PollAnswers) -> Result<()> { + let mut affected_rows = 0; + for answer in answers.answers { + affected_rows += diesel::insert_into(answers::table) + .values(Answer { + id: None, + choice_id: answer.choice_id, + trader_pubkey: answers.trader_pk.to_string(), + value: answer.value, + creation_timestamp: OffsetDateTime::now_utc(), + }) + .execute(conn)?; + } + + if affected_rows == 0 { + bail!( + "Could not insert answers by user {}.", + answers.trader_pk.to_string() + ); + } else { + tracing::trace!(%affected_rows, trade_pk = answers.trader_pk.to_string(), + "Added new answers to a poll."); + } + Ok(()) +} diff --git a/coordinator/src/routes.rs b/coordinator/src/routes.rs index ad735f267..8301d0c3e 100644 --- a/coordinator/src/routes.rs +++ b/coordinator/src/routes.rs @@ -56,6 +56,8 @@ use commons::CollaborativeRevertTraderResponse; use commons::DeleteBackup; use commons::Message; use commons::OnboardingParam; +use commons::Poll; +use commons::PollAnswers; use commons::RegisterParams; use commons::Restore; use commons::RouteHintHop; @@ -132,6 +134,7 @@ pub fn router( Router::new() .route("/", get(index)) .route("/api/version", get(version)) + .route("/api/polls", get(get_polls).post(post_poll_answer)) .route( "/api/fee_rate_estimate/:target", get(get_fee_rate_estimation), @@ -523,6 +526,37 @@ pub async fn version() -> Result, AppError> { })) } +pub async fn get_polls(State(state): State>) -> Result>, AppError> { + let mut connection = state + .pool + .get() + .map_err(|_| AppError::InternalServerError("Could not get db connection".to_string()))?; + let polls = db::polls::active(&mut connection).map_err(|error| { + AppError::InternalServerError(format!("Could not fetch new polls {error}")) + })?; + Ok(Json(polls)) +} +pub async fn post_poll_answer( + State(state): State>, + poll_answer: Json, +) -> Result<(), AppError> { + tracing::trace!( + poll_id = poll_answer.poll_id, + trader_pk = poll_answer.trader_pk.to_string(), + answers = ?poll_answer.answers, + "Received new answer"); + let mut connection = state + .pool + .get() + .map_err(|_| AppError::InternalServerError("Could not get db connection".to_string()))?; + + db::polls::add_answer(&mut connection, poll_answer.0).map_err(|error| { + AppError::InternalServerError(format!("Could not save answer in db: {error:?}")) + })?; + + Ok(()) +} + #[instrument(skip_all, err(Debug))] pub async fn collaborative_revert_confirm( State(state): State>, diff --git a/coordinator/src/schema.rs b/coordinator/src/schema.rs index 0101fc4db..28d596453 100644 --- a/coordinator/src/schema.rs +++ b/coordinator/src/schema.rs @@ -41,11 +41,25 @@ pub mod sql_types { #[diesel(postgres_type(name = "Payment_Flow_Type"))] pub struct PaymentFlowType; + #[derive(diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "Poll_Type_Type"))] + pub struct PollTypeType; + #[derive(diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "PositionState_Type"))] pub struct PositionStateType; } +diesel::table! { + answers (id) { + id -> Int4, + choice_id -> Int4, + trader_pubkey -> Text, + value -> Text, + creation_timestamp -> Timestamptz, + } +} + diesel::table! { use diesel::sql_types::*; use super::sql_types::ChannelStateType; @@ -65,6 +79,14 @@ diesel::table! { } } +diesel::table! { + choices (id) { + id -> Int4, + poll_id -> Int4, + value -> Text, + } +} + diesel::table! { collaborative_reverts (id) { id -> Int4, @@ -209,6 +231,19 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::PollTypeType; + + polls (id) { + id -> Int4, + poll_type -> PollTypeType, + question -> Text, + active -> Bool, + creation_timestamp -> Timestamptz, + } +} + diesel::table! { use diesel::sql_types::*; use super::sql_types::ContractSymbolType; @@ -301,12 +336,16 @@ diesel::table! { } } +diesel::joinable!(answers -> choices (choice_id)); +diesel::joinable!(choices -> polls (poll_id)); diesel::joinable!(last_outbound_dlc_messages -> dlc_messages (message_hash)); diesel::joinable!(liquidity_request_logs -> liquidity_options (liquidity_option)); diesel::joinable!(trades -> positions (position_id)); diesel::allow_tables_to_appear_in_same_query!( + answers, channels, + choices, collaborative_reverts, dlc_messages, last_outbound_dlc_messages, @@ -316,6 +355,7 @@ diesel::allow_tables_to_appear_in_same_query!( matches, orders, payments, + polls, positions, routing_fees, spendable_outputs, diff --git a/crates/commons/src/lib.rs b/crates/commons/src/lib.rs index 9f74c4837..97eaedfa0 100644 --- a/crates/commons/src/lib.rs +++ b/crates/commons/src/lib.rs @@ -9,6 +9,7 @@ mod liquidity_option; mod message; mod order; mod order_matching_fee; +mod polls; mod price; mod rollover; mod route; @@ -21,6 +22,7 @@ pub use crate::liquidity_option::*; pub use crate::message::*; pub use crate::order::*; pub use crate::order_matching_fee::order_matching_fee_taker; +pub use crate::polls::*; pub use crate::price::best_current_price; pub use crate::price::Price; pub use crate::price::Prices; diff --git a/crates/commons/src/polls.rs b/crates/commons/src/polls.rs new file mode 100644 index 000000000..e48b7bc9b --- /dev/null +++ b/crates/commons/src/polls.rs @@ -0,0 +1,49 @@ +use anyhow::bail; +use secp256k1::PublicKey; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Poll { + pub id: i32, + pub poll_type: PollType, + pub question: String, + pub choices: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Choice { + pub id: i32, + pub value: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Answer { + pub choice_id: i32, + pub value: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum PollType { + SingleChoice, +} + +impl TryFrom<&str> for PollType { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + match value.to_lowercase().as_str() { + "single_choice" => Ok(PollType::SingleChoice), + _ => { + bail!("Unsupported poll type") + } + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct PollAnswers { + pub poll_id: i32, + pub trader_pk: PublicKey, + pub answers: Vec, +} diff --git a/mobile/lib/common/init_service.dart b/mobile/lib/common/init_service.dart index 41a2cd81a..58b66a8e2 100644 --- a/mobile/lib/common/init_service.dart +++ b/mobile/lib/common/init_service.dart @@ -32,6 +32,8 @@ import 'package:get_10101/features/wallet/domain/wallet_info.dart'; import 'package:get_10101/common/application/event_service.dart'; import 'package:get_10101/logger/logger.dart'; import 'package:get_10101/util/environment.dart'; +import 'package:get_10101/util/poll_change_notified.dart'; +import 'package:get_10101/util/poll_service.dart'; import 'package:nested/nested.dart'; import 'package:provider/provider.dart'; import 'package:get_10101/bridge_generated/bridge_definitions.dart' as bridge; @@ -41,6 +43,7 @@ List createProviders() { const ChannelInfoService channelInfoService = ChannelInfoService(); var tradeValuesService = TradeValuesService(); + const pollService = PollService(); var providers = [ ChangeNotifierProvider(create: (context) { @@ -62,8 +65,10 @@ List createProviders() { ChangeNotifierProvider(create: (context) => PaymentChangeNotifier()), ChangeNotifierProvider(create: (context) => CollabRevertChangeNotifier()), ChangeNotifierProvider(create: (context) => LspChangeNotifier(channelInfoService)), + ChangeNotifierProvider(create: (context) => PollChangeNotifier(pollService)), Provider(create: (context) => config), - Provider(create: (context) => channelInfoService) + Provider(create: (context) => channelInfoService), + Provider(create: (context) => pollService) ]; if (config.network == "regtest") { providers.add(Provider(create: (context) => FaucetService())); diff --git a/mobile/lib/common/poll_widget.dart b/mobile/lib/common/poll_widget.dart new file mode 100644 index 000000000..643f2c1ec --- /dev/null +++ b/mobile/lib/common/poll_widget.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import 'package:get_10101/bridge_generated/bridge_definitions.dart'; +import 'package:get_10101/common/snack_bar.dart'; +import 'package:get_10101/util/poll_change_notified.dart'; +import 'package:provider/provider.dart'; + +class PollWidget extends StatefulWidget { + const PollWidget({super.key}); + + @override + State createState() => _PollWidgetState(); +} + +class _PollWidgetState extends State { + int? _selectedAnswer; + bool showPoll = true; + + @override + Widget build(BuildContext context) { + final PollChangeNotifier pollChangeNotifier = context.watch(); + var poll = pollChangeNotifier.poll; + if (poll == null || !showPoll) { + return const SizedBox.shrink(); + } + + return Card( + margin: const EdgeInsets.all(0), + elevation: 1, + child: Stack( + children: [ + ExpansionTile( + trailing: const SizedBox.shrink(), + title: const Text("Time for a quick survey?"), + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Stack(children: [ + Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 10.0, bottom: 10), + child: RichText( + text: TextSpan( + text: poll.question, + style: const TextStyle( + fontWeight: FontWeight.w500, color: Colors.black, fontSize: 20), + )), + ), + ) + ]), + Column( + children: poll.choices + .map( + (choice) => PollChoice( + label: choice.value, + padding: const EdgeInsets.symmetric(horizontal: 5.0), + value: choice.id, + groupValue: _selectedAnswer, + onChanged: (int newValue) { + setState(() { + _selectedAnswer = newValue; + }); + }, + ), + ) + .toList()), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + await pollChangeNotifier.ignore(poll); + showSnackBar(messenger, "Poll won't be shown again"); + await pollChangeNotifier.refresh(); + }, + style: ButtonStyle( + padding: + MaterialStateProperty.all(const EdgeInsets.all(15)), + backgroundColor: MaterialStateProperty.all(Colors.white), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + RichText( + text: const TextSpan( + text: "Ignore this poll", + style: TextStyle( + fontWeight: FontWeight.w500, + color: Colors.black, + fontSize: 15, + decoration: TextDecoration.underline, + ), + ), + ), + const Icon( + Icons.cancel, + size: 16, + color: Colors.black, + ), + ], + ), + ), + ElevatedButton( + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + if (_selectedAnswer == null) { + showSnackBar(messenger, "Please provide an answer"); + } else { + await answerPoll(pollChangeNotifier, poll, _selectedAnswer!); + } + }, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.all(8.0), + backgroundColor: Colors.grey.shade200, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + RichText( + text: const TextSpan( + text: "Submit", + style: TextStyle( + fontWeight: FontWeight.w500, + color: Colors.black, + fontSize: 15, + ), + ), + ), + const Icon( + Icons.send, + size: 16, + color: Colors.black, + ), + ], + ), + ), + ], + ), + ), + ], + ) + ], + ), + Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: IconButton( + onPressed: () => setState(() { + showPoll = false; + }), + icon: const Icon( + Icons.close, + size: 28, + ), + ), + )), + ], + )); + } + + Future answerPoll( + PollChangeNotifier pollChangeNotifier, Poll poll, int selectedAnswer) async { + await pollChangeNotifier.answer( + poll.choices.firstWhere((choice) => choice.id == selectedAnswer), poll); + } +} + +class PollChoice extends StatelessWidget { + const PollChoice({ + super.key, + required this.label, + required this.padding, + required this.groupValue, + required this.value, + required this.onChanged, + }); + + final String label; + final EdgeInsets padding; + final int? groupValue; + final int value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + // return Container(color: Colors.blue,); + return InkWell( + onTap: () { + if (value != groupValue) { + onChanged(value); + } + }, + child: Padding( + padding: padding, + child: Row( + children: [ + Radio( + groupValue: groupValue, + value: value, + onChanged: (int? newValue) { + onChanged(newValue!); + }, + ), + Text(label), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/common/settings/emergency_kit_screen.dart b/mobile/lib/common/settings/emergency_kit_screen.dart index 0c4aee725..65011b580 100644 --- a/mobile/lib/common/settings/emergency_kit_screen.dart +++ b/mobile/lib/common/settings/emergency_kit_screen.dart @@ -120,6 +120,42 @@ class _EmergencyKitScreenState extends State { Icon(FontAwesomeIcons.broom), SizedBox(width: 10), Text("Cleanup filling orders", style: TextStyle(fontSize: 16)) + ])), + const SizedBox( + height: 30, + ), + OutlinedButton( + onPressed: () { + final messenger = ScaffoldMessenger.of(context); + try { + rust.api.resetAllAnsweredPolls(); + showSnackBar(messenger, + "Successfully reset answered polls - You can now answer them again"); + } catch (e) { + showSnackBar(messenger, "Failed to reset answered polls: $e"); + } + }, + style: ButtonStyle( + fixedSize: MaterialStateProperty.all(const Size(double.infinity, 50)), + iconSize: MaterialStateProperty.all(20.0), + elevation: MaterialStateProperty.all(0), + // this reduces the shade + side: MaterialStateProperty.all( + const BorderSide(width: 1.0, color: tenTenOnePurple)), + padding: MaterialStateProperty.all( + const EdgeInsets.fromLTRB(20, 12, 20, 12), + ), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + ), + backgroundColor: MaterialStateProperty.all(Colors.transparent), + ), + child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(FontAwesomeIcons.broom), + SizedBox(width: 10), + Text("Reset answered poll cache", style: TextStyle(fontSize: 16)) ])) ], ), diff --git a/mobile/lib/features/wallet/wallet_screen.dart b/mobile/lib/features/wallet/wallet_screen.dart index f8885a04b..620d85ced 100644 --- a/mobile/lib/features/wallet/wallet_screen.dart +++ b/mobile/lib/features/wallet/wallet_screen.dart @@ -2,11 +2,13 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get_10101/common/channel_status_notifier.dart'; +import 'package:get_10101/common/poll_widget.dart'; import 'package:get_10101/common/secondary_action_button.dart'; import 'package:get_10101/features/wallet/balance.dart'; import 'package:get_10101/features/wallet/receive_screen.dart'; import 'package:get_10101/features/wallet/scanner_screen.dart'; import 'package:get_10101/features/wallet/wallet_change_notifier.dart'; +import 'package:get_10101/util/poll_change_notified.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; @@ -18,8 +20,8 @@ class WalletScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final pollChangeNotifier = context.watch(); final walletChangeNotifier = context.watch(); - final hasChannel = context.watch().hasDlcChannel(); return Scaffold( @@ -27,6 +29,7 @@ class WalletScreen extends StatelessWidget { onRefresh: () async { await walletChangeNotifier.refreshWalletInfo(); await walletChangeNotifier.waitForSyncToComplete(); + await pollChangeNotifier.refresh(); }, child: Container( margin: const EdgeInsets.only(top: 7.0), @@ -70,14 +73,22 @@ class WalletScreen extends StatelessWidget { ), child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), - child: Card( - margin: const EdgeInsets.all(0.0), - elevation: 1, - child: Column( - children: walletChangeNotifier.walletInfo.history - .map((e) => e.toWidget()) - .toList(), - ), + child: Column( + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 8.0), + child: PollWidget(), + ), + Card( + margin: const EdgeInsets.all(0.0), + elevation: 1, + child: Column( + children: walletChangeNotifier.walletInfo.history + .map((e) => e.toWidget()) + .toList(), + ), + ), + ], ), ), ), diff --git a/mobile/lib/util/poll_change_notified.dart b/mobile/lib/util/poll_change_notified.dart new file mode 100644 index 000000000..a3f56d99d --- /dev/null +++ b/mobile/lib/util/poll_change_notified.dart @@ -0,0 +1,36 @@ +import 'package:flutter/widgets.dart'; +import 'package:get_10101/ffi.dart'; +import 'package:get_10101/logger/logger.dart'; +import 'package:get_10101/util/poll_service.dart'; + +class PollChangeNotifier extends ChangeNotifier { + final PollService service; + Poll? _polls; + + Poll? get poll => _polls; + + PollChangeNotifier(this.service) { + refresh(); + } + + Future refresh() async { + try { + final poll = await service.fetchPoll(); + _polls = poll; + + super.notifyListeners(); + } catch (error) { + logger.e(error); + } + } + + Future answer(Choice answer, Poll poll) async { + await service.postAnswer(answer, poll); + refresh(); + } + + Future ignore(Poll poll) async { + service.ignorePoll(poll.id); + refresh(); + } +} diff --git a/mobile/lib/util/poll_service.dart b/mobile/lib/util/poll_service.dart new file mode 100644 index 000000000..1b05c3d55 --- /dev/null +++ b/mobile/lib/util/poll_service.dart @@ -0,0 +1,23 @@ +import 'package:get_10101/ffi.dart' as rust; +import 'package:get_10101/logger/logger.dart'; + +class PollService { + const PollService(); + + Future fetchPoll() async { + try { + return await rust.api.fetchPoll(); + } catch (error) { + logger.e("Failed to fetch polls: $error"); + return null; + } + } + + Future postAnswer(rust.Choice choice, rust.Poll poll) async { + return await rust.api.postSelectedChoice(pollId: poll.id, selectedChoice: choice); + } + + void ignorePoll(int pollId) { + return rust.api.ignorePoll(pollId: pollId); + } +} diff --git a/mobile/native/migrations/2024-02-05-120600_answered_polls/down.sql b/mobile/native/migrations/2024-02-05-120600_answered_polls/down.sql new file mode 100644 index 000000000..77987506c --- /dev/null +++ b/mobile/native/migrations/2024-02-05-120600_answered_polls/down.sql @@ -0,0 +1,2 @@ +drop table if exists answered_polls; +drop table if exists ignored_polls; diff --git a/mobile/native/migrations/2024-02-05-120600_answered_polls/up.sql b/mobile/native/migrations/2024-02-05-120600_answered_polls/up.sql new file mode 100644 index 000000000..8fdf17e14 --- /dev/null +++ b/mobile/native/migrations/2024-02-05-120600_answered_polls/up.sql @@ -0,0 +1,14 @@ +create table answered_polls +( + id INTEGER PRIMARY KEY NOT NULL, + poll_id INTEGER NOT NULL, + timestamp BIGINT NOT NULL + +); + +create table ignored_polls +( + id INTEGER PRIMARY KEY NOT NULL, + poll_id INTEGER NOT NULL, + timestamp BIGINT NOT NULL +); diff --git a/mobile/native/src/api.rs b/mobile/native/src/api.rs index 83a5a94ec..3f2796d28 100644 --- a/mobile/native/src/api.rs +++ b/mobile/native/src/api.rs @@ -17,6 +17,7 @@ use crate::ln_dlc::get_storage; use crate::ln_dlc::FUNDING_TX_WEIGHT_ESTIMATE; use crate::logger; use crate::orderbook; +use crate::polls; use crate::trade::order; use crate::trade::order::api::NewOrder; use crate::trade::order::api::Order; @@ -98,6 +99,94 @@ pub async fn sync_dlc_channels() -> Result<()> { Ok(()) } +#[derive(Debug, Clone)] +pub struct Poll { + pub id: i32, + pub poll_type: PollType, + pub question: String, + pub choices: Vec, +} + +#[derive(Debug, Clone)] +pub struct Choice { + pub id: i32, + pub value: String, +} + +#[derive(Debug, Clone)] +pub enum PollType { + SingleChoice, +} + +impl From for Poll { + fn from(value: commons::Poll) -> Self { + Poll { + id: value.id, + poll_type: value.poll_type.into(), + question: value.question, + choices: value + .choices + .into_iter() + .map(|choice| choice.into()) + .collect(), + } + } +} + +impl From for PollType { + fn from(value: commons::PollType) -> Self { + match value { + commons::PollType::SingleChoice => PollType::SingleChoice, + } + } +} + +impl From for Choice { + fn from(value: commons::Choice) -> Self { + Choice { + id: value.id, + value: value.value, + } + } +} + +impl From for commons::Choice { + fn from(value: Choice) -> Self { + commons::Choice { + id: value.id, + value: value.value, + } + } +} + +#[tokio::main(flavor = "current_thread")] +pub async fn fetch_poll() -> Result> { + let polls: Vec = polls::get_new_polls() + .await? + .into_iter() + .map(|poll| poll.into()) + .collect(); + // For now we just return the first poll + Ok(polls.first().cloned()) +} + +#[tokio::main(flavor = "current_thread")] +pub async fn post_selected_choice(selected_choice: Choice, poll_id: i32) -> Result<()> { + let trader_pk = ln_dlc::get_node_pubkey(); + polls::answer_poll(selected_choice.into(), poll_id, trader_pk).await?; + Ok(()) +} + +pub fn reset_all_answered_polls() -> Result> { + db::delete_answered_poll_cache()?; + Ok(SyncReturn(())) +} + +pub fn ignore_poll(poll_id: i32) -> Result> { + polls::ignore_poll(poll_id)?; + Ok(SyncReturn(())) +} + pub fn refresh_lightning_wallet() -> Result<()> { ln_dlc::refresh_lightning_wallet() } diff --git a/mobile/native/src/db/mod.rs b/mobile/native/src/db/mod.rs index c42bd5ca4..7c62685f4 100644 --- a/mobile/native/src/db/mod.rs +++ b/mobile/native/src/db/mod.rs @@ -45,6 +45,7 @@ mod custom_types; pub mod dlc_messages; pub mod last_outbound_dlc_messages; pub mod models; +pub mod polls; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); @@ -579,3 +580,25 @@ pub fn insert_trade(trade: crate::trade::Trade) -> Result<()> { Ok(()) } + +/// Returns a list of polls which have been answered or should be ignored +pub fn load_ignored_or_answered_polls() -> Result> { + let mut db = connection()?; + let answered_polls = polls::get(&mut db)?; + for i in &answered_polls { + tracing::debug!(id = i.poll_id, "Ignored poll") + } + Ok(answered_polls) +} + +/// A poll inserted into this table was either answered or should be ignored in the future. +pub fn set_poll_to_ignored_or_answered(poll_id: i32) -> Result<()> { + let mut db = connection()?; + polls::insert(&mut db, poll_id)?; + Ok(()) +} +pub fn delete_answered_poll_cache() -> Result<()> { + let mut db = connection()?; + polls::delete_all(&mut db)?; + Ok(()) +} diff --git a/mobile/native/src/db/polls.rs b/mobile/native/src/db/polls.rs new file mode 100644 index 000000000..a1b6f7c1c --- /dev/null +++ b/mobile/native/src/db/polls.rs @@ -0,0 +1,50 @@ +use crate::schema; +use crate::schema::answered_polls; +use anyhow::ensure; +use anyhow::Result; +use diesel::Insertable; +use diesel::QueryResult; +use diesel::Queryable; +use diesel::QueryableByName; +use diesel::RunQueryDsl; +use diesel::SqliteConnection; +use time::OffsetDateTime; + +#[derive(Insertable, Debug, Clone, PartialEq)] +#[diesel(table_name = answered_polls)] +pub struct NewAnsweredOrIgnored { + pub poll_id: i32, + pub timestamp: i64, +} + +#[derive(QueryableByName, Queryable, Debug, Clone, PartialEq)] +#[diesel(table_name = answered_polls)] +pub struct AnsweredOrIgnored { + pub id: i32, + pub poll_id: i32, + pub timestamp: i64, +} + +pub(crate) fn get(conn: &mut SqliteConnection) -> QueryResult> { + let result = schema::answered_polls::table.load(conn)?; + Ok(result) +} + +pub(crate) fn insert(conn: &mut SqliteConnection, poll_id: i32) -> Result<()> { + let affected_rows = diesel::insert_into(schema::answered_polls::table) + .values(NewAnsweredOrIgnored { + poll_id, + timestamp: OffsetDateTime::now_utc().unix_timestamp(), + }) + .execute(conn)?; + + ensure!(affected_rows > 0, "Could not insert answered poll"); + + Ok(()) +} + +pub(crate) fn delete_all(conn: &mut SqliteConnection) -> Result<()> { + diesel::delete(schema::answered_polls::table).execute(conn)?; + + Ok(()) +} diff --git a/mobile/native/src/lib.rs b/mobile/native/src/lib.rs index fedda3345..c5266898a 100644 --- a/mobile/native/src/lib.rs +++ b/mobile/native/src/lib.rs @@ -27,4 +27,5 @@ mod channel_trade_constraints; mod cipher; mod destination; mod dlc_handler; +mod polls; mod storage; diff --git a/mobile/native/src/polls.rs b/mobile/native/src/polls.rs new file mode 100644 index 000000000..441d4fab3 --- /dev/null +++ b/mobile/native/src/polls.rs @@ -0,0 +1,74 @@ +use crate::commons::reqwest_client; +use crate::config; +use crate::db; +use anyhow::Result; +use bitcoin::secp256k1::PublicKey; +use commons::Answer; +use commons::Choice; +use commons::Poll; +use commons::PollAnswers; +use reqwest::Url; + +pub(crate) async fn get_new_polls() -> Result> { + let new_polls = fetch_polls().await?; + tracing::debug!(new_polls = new_polls.len(), "Fetched new polls"); + let answered_polls = db::load_ignored_or_answered_polls()?; + let unanswered_polls = new_polls + .into_iter() + .filter(|poll| { + !answered_polls + .iter() + .any(|answered_poll| answered_poll.poll_id == poll.id) + }) + .collect::>(); + tracing::debug!(unanswered_polls = unanswered_polls.len(), "Polls to answer"); + for i in &unanswered_polls { + tracing::debug!(poll_id = i.id, "Unanswered polls"); + } + Ok(unanswered_polls) +} + +pub(crate) async fn answer_poll(choice: Choice, poll_id: i32, trader_pk: PublicKey) -> Result<()> { + post_selected_choice(choice.clone(), poll_id, trader_pk).await?; + db::set_poll_to_ignored_or_answered(poll_id)?; + tracing::debug!(poll_id, choice = ?choice, "Answered poll"); + + Ok(()) +} + +pub(crate) fn ignore_poll(poll_id: i32) -> Result<()> { + db::set_poll_to_ignored_or_answered(poll_id)?; + tracing::debug!(poll_id, "Poll won't be shown again"); + Ok(()) +} + +async fn fetch_polls() -> Result> { + let client = reqwest_client(); + let url = format!("http://{}", config::get_http_endpoint()); + let url = Url::parse(&url).expect("correct URL"); + let url = url.join("/api/polls")?; + let response = client.get(url).send().await?; + let polls = response.json().await?; + Ok(polls) +} + +async fn post_selected_choice(choice: Choice, poll_id: i32, trader_pk: PublicKey) -> Result<()> { + let client = reqwest_client(); + let url = format!("http://{}", config::get_http_endpoint()); + let url = Url::parse(&url).expect("correct URL"); + let url = url.join("/api/polls")?; + let response = client + .post(url) + .json(&PollAnswers { + poll_id, + trader_pk, + answers: vec![Answer { + choice_id: choice.id, + value: choice.value, + }], + }) + .send() + .await?; + response.error_for_status()?; + Ok(()) +} diff --git a/mobile/native/src/schema.rs b/mobile/native/src/schema.rs index 8dcd9856c..505a50e17 100644 --- a/mobile/native/src/schema.rs +++ b/mobile/native/src/schema.rs @@ -1,5 +1,13 @@ // @generated automatically by Diesel CLI. +diesel::table! { + answered_polls (id) { + id -> Integer, + poll_id -> Integer, + timestamp -> BigInt, + } +} + diesel::table! { channels (user_channel_id) { user_channel_id -> Text, @@ -27,6 +35,14 @@ diesel::table! { } } +diesel::table! { + ignored_polls (id) { + id -> Integer, + poll_id -> Integer, + timestamp -> BigInt, + } +} + diesel::table! { last_outbound_dlc_messages (peer_id) { peer_id -> Text, @@ -126,8 +142,10 @@ diesel::table! { diesel::joinable!(last_outbound_dlc_messages -> dlc_messages (message_hash)); diesel::allow_tables_to_appear_in_same_query!( + answered_polls, channels, dlc_messages, + ignored_polls, last_outbound_dlc_messages, orders, payments, From c4bf8aea1d5c7b2e1b72a4589fc57ae063ef04c1 Mon Sep 17 00:00:00 2001 From: Philipp Hoenisch Date: Tue, 6 Feb 2024 19:36:14 +0100 Subject: [PATCH 2/2] chore: only make reset poll visible in regtest --- .../common/settings/emergency_kit_screen.dart | 68 ++++++++++--------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/mobile/lib/common/settings/emergency_kit_screen.dart b/mobile/lib/common/settings/emergency_kit_screen.dart index 65011b580..e87cecb39 100644 --- a/mobile/lib/common/settings/emergency_kit_screen.dart +++ b/mobile/lib/common/settings/emergency_kit_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:get_10101/bridge_generated/bridge_definitions.dart' as bridge; import 'package:get_10101/common/color.dart'; import 'package:get_10101/common/custom_app_bar.dart'; import 'package:get_10101/common/settings/settings_screen.dart'; @@ -22,6 +23,8 @@ class EmergencyKitScreen extends StatefulWidget { class _EmergencyKitScreenState extends State { @override Widget build(BuildContext context) { + final bridge.Config config = context.read(); + return Scaffold( body: SafeArea( child: Container( @@ -124,39 +127,42 @@ class _EmergencyKitScreenState extends State { const SizedBox( height: 30, ), - OutlinedButton( - onPressed: () { - final messenger = ScaffoldMessenger.of(context); - try { - rust.api.resetAllAnsweredPolls(); - showSnackBar(messenger, - "Successfully reset answered polls - You can now answer them again"); - } catch (e) { - showSnackBar(messenger, "Failed to reset answered polls: $e"); - } - }, - style: ButtonStyle( - fixedSize: MaterialStateProperty.all(const Size(double.infinity, 50)), - iconSize: MaterialStateProperty.all(20.0), - elevation: MaterialStateProperty.all(0), - // this reduces the shade - side: MaterialStateProperty.all( - const BorderSide(width: 1.0, color: tenTenOnePurple)), - padding: MaterialStateProperty.all( - const EdgeInsets.fromLTRB(20, 12, 20, 12), - ), - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), + Visibility( + visible: config.network == "regtest", + child: OutlinedButton( + onPressed: () { + final messenger = ScaffoldMessenger.of(context); + try { + rust.api.resetAllAnsweredPolls(); + showSnackBar(messenger, + "Successfully reset answered polls - You can now answer them again"); + } catch (e) { + showSnackBar(messenger, "Failed to reset answered polls: $e"); + } + }, + style: ButtonStyle( + fixedSize: MaterialStateProperty.all(const Size(double.infinity, 50)), + iconSize: MaterialStateProperty.all(20.0), + elevation: MaterialStateProperty.all(0), + // this reduces the shade + side: MaterialStateProperty.all( + const BorderSide(width: 1.0, color: tenTenOnePurple)), + padding: MaterialStateProperty.all( + const EdgeInsets.fromLTRB(20, 12, 20, 12), + ), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), ), + backgroundColor: MaterialStateProperty.all(Colors.transparent), ), - backgroundColor: MaterialStateProperty.all(Colors.transparent), - ), - child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(FontAwesomeIcons.broom), - SizedBox(width: 10), - Text("Reset answered poll cache", style: TextStyle(fontSize: 16)) - ])) + child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(FontAwesomeIcons.broom), + SizedBox(width: 10), + Text("Reset answered poll cache", style: TextStyle(fontSize: 16)) + ])), + ) ], ), )