gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-cashier-terminal-android] 04/06: First complete prototype


From: gnunet
Subject: [taler-cashier-terminal-android] 04/06: First complete prototype
Date: Wed, 19 Feb 2020 21:31:58 +0100

This is an automated email from the git hooks/post-receive script.

torsten-grote pushed a commit to branch master
in repository cashier-terminal-android.

commit 146c81c00f465dfc3872c7cb4ba4adae9bc0519f
Author: Torsten Grote <address@hidden>
AuthorDate: Wed Feb 19 10:36:53 2020 -0300

    First complete prototype
---
 .idea/dictionaries/user.xml                        |   7 +
 app/build.gradle                                   |   6 +-
 app/src/main/java/net/taler/cashier/Amount.kt      |  12 ++
 .../main/java/net/taler/cashier/BalanceFragment.kt |   1 +
 .../main/java/net/taler/cashier/ConfigFragment.kt  |  23 ++-
 app/src/main/java/net/taler/cashier/HttpHelper.kt  |  49 +++++-
 .../main/java/net/taler/cashier/MainViewModel.kt   | 123 ++++----------
 .../java/net/taler/cashier/TransactionFragment.kt  |  86 ----------
 app/src/main/java/net/taler/cashier/Utils.kt       |  21 +++
 .../net/taler/cashier/withdraw/ErrorFragment.kt    |  33 ++++
 .../net/taler/cashier/{ => withdraw}/NfcManager.kt |  10 +-
 .../taler/cashier/{ => withdraw}/QrCodeManager.kt  |   2 +-
 .../net/taler/cashier/withdraw/SuccessFragment.kt  |  33 ++++
 .../taler/cashier/withdraw/TransactionFragment.kt  | 146 ++++++++++++++++
 .../cashier/{ => withdraw}/WithdrawFragment.kt     |  27 ++-
 .../net/taler/cashier/withdraw/WithdrawManager.kt  | 184 +++++++++++++++++++++
 app/src/main/res/drawable/ic_check_circle.xml      |  11 ++
 app/src/main/res/drawable/ic_error.xml             |  11 ++
 .../fragment_transaction.xml                       |  26 ++-
 app/src/main/res/layout/activity_main.xml          |   5 +-
 app/src/main/res/layout/fragment_balance.xml       |   5 +-
 app/src/main/res/layout/fragment_config.xml        |   5 +-
 app/src/main/res/layout/fragment_error.xml         |  49 ++++++
 app/src/main/res/layout/fragment_success.xml       |  49 ++++++
 app/src/main/res/layout/fragment_transaction.xml   |   8 +-
 app/src/main/res/layout/fragment_withdraw.xml      |  19 +--
 app/src/main/res/navigation/nav_graph.xml          |  27 ++-
 app/src/main/res/values/colors.xml                 |   3 +
 app/src/main/res/values/strings.xml                |   8 +-
 app/src/main/res/values/styles.xml                 |  16 +-
 build.gradle                                       |   2 +-
 31 files changed, 763 insertions(+), 244 deletions(-)

diff --git a/.idea/dictionaries/user.xml b/.idea/dictionaries/user.xml
new file mode 100644
index 0000000..6a82105
--- /dev/null
+++ b/.idea/dictionaries/user.xml
@@ -0,0 +1,7 @@
+<component name="ProjectDictionaryState">
+  <dictionary name="user">
+    <words>
+      <w>taler</w>
+    </words>
+  </dictionary>
+</component>
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index 055f4ba..217ad35 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -32,10 +32,10 @@ android {
 dependencies {
     implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
     implementation 'androidx.appcompat:appcompat:1.1.0'
-    implementation 'androidx.core:core-ktx:1.1.0'
+    implementation 'androidx.core:core-ktx:1.2.0'
     implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
     implementation 'androidx.security:security-crypto:1.0.0-alpha02'
-    implementation 'com.google.android.material:material:1.1.0'
+    implementation 'com.google.android.material:material:1.2.0-alpha04'
 
     implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
     implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
@@ -50,7 +50,7 @@ dependencies {
     implementation "com.squareup.okhttp3:okhttp:3.12.6"
 
     implementation 'androidx.legacy:legacy-support-v4:1.0.0'
-    testImplementation 'junit:junit:4.12'
+    testImplementation 'junit:junit:4.13'
 
     androidTestImplementation 'androidx.test.ext:junit:1.1.1'
     androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
diff --git a/app/src/main/java/net/taler/cashier/Amount.kt 
b/app/src/main/java/net/taler/cashier/Amount.kt
index c1307e9..5aaae65 100644
--- a/app/src/main/java/net/taler/cashier/Amount.kt
+++ b/app/src/main/java/net/taler/cashier/Amount.kt
@@ -4,10 +4,22 @@ data class Amount(val currency: String, val amount: String) {
 
     companion object {
 
+        private val SIGNED_REGEX = Regex("""([+\-])(\w+):([0-9.]+)""")
+
         fun fromString(strAmount: String): Amount {
             val components = strAmount.split(":")
             return Amount(components[0], components[1])
         }
+
+        fun fromStringSigned(strAmount: String): Amount? {
+            val groups = SIGNED_REGEX.matchEntire(strAmount)?.groupValues ?: 
emptyList()
+            if (groups.size < 4) return null
+            var amount = groups[3].toDoubleOrNull() ?: return null
+            if (groups[1] == "-") amount *= -1
+            val currency = groups[2]
+            return Amount(currency, String.format("%.2f", amount))
+        }
+
     }
 
 }
diff --git a/app/src/main/java/net/taler/cashier/BalanceFragment.kt 
b/app/src/main/java/net/taler/cashier/BalanceFragment.kt
index 90e2e8b..50e8488 100644
--- a/app/src/main/java/net/taler/cashier/BalanceFragment.kt
+++ b/app/src/main/java/net/taler/cashier/BalanceFragment.kt
@@ -30,6 +30,7 @@ class BalanceFragment : Fragment() {
         viewModel.balance.observe(viewLifecycleOwner, Observer { balance ->
             balanceView.text = balance
             progressBar.visibility = INVISIBLE
+            fab.show()
         })
         fab.setOnClickListener {
             
BalanceFragmentDirections.actionBalanceFragmentToWithdrawFragment().let {
diff --git a/app/src/main/java/net/taler/cashier/ConfigFragment.kt 
b/app/src/main/java/net/taler/cashier/ConfigFragment.kt
index fc53124..e48a32d 100644
--- a/app/src/main/java/net/taler/cashier/ConfigFragment.kt
+++ b/app/src/main/java/net/taler/cashier/ConfigFragment.kt
@@ -16,6 +16,8 @@ import com.google.android.material.snackbar.Snackbar
 import com.google.android.material.snackbar.Snackbar.LENGTH_LONG
 import kotlinx.android.synthetic.main.fragment_config.*
 
+private const val URL_BANK_TEST = "https://bank.test.taler.net";
+
 class ConfigFragment : Fragment() {
 
     private val viewModel: MainViewModel by activityViewModels()
@@ -30,9 +32,17 @@ class ConfigFragment : Fragment() {
 
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         if (savedInstanceState == null) {
-            urlView.editText!!.setText(viewModel.config.bankUrl)
+            if (viewModel.config.bankUrl.isBlank() && BuildConfig.DEBUG) {
+                urlView.editText!!.setText(URL_BANK_TEST)
+            } else {
+                urlView.editText!!.setText(viewModel.config.bankUrl)
+            }
             usernameView.editText!!.setText(viewModel.config.username)
             passwordView.editText!!.setText(viewModel.config.password)
+        } else {
+            
urlView.editText!!.setText(savedInstanceState.getCharSequence("urlView"))
+            
usernameView.editText!!.setText(savedInstanceState.getCharSequence("usernameView"))
+            
passwordView.editText!!.setText(savedInstanceState.getCharSequence("passwordView"))
         }
         saveButton.setOnClickListener {
             val config = Config(
@@ -65,6 +75,14 @@ class ConfigFragment : Fragment() {
         }
     }
 
+    override fun onSaveInstanceState(outState: Bundle) {
+        super.onSaveInstanceState(outState)
+        // for some reason automatic restore isn't working at the moment!?
+        outState.putCharSequence("urlView", urlView.editText?.text)
+        outState.putCharSequence("usernameView", usernameView.editText?.text)
+        outState.putCharSequence("passwordView", passwordView.editText?.text)
+    }
+
     private fun checkConfig(config: Config): Boolean {
         if (!config.bankUrl.startsWith("https://";)) {
             urlView.error = getString(R.string.config_bank_url_error)
@@ -86,7 +104,8 @@ class ConfigFragment : Fragment() {
             val action = 
ConfigFragmentDirections.actionConfigFragmentToBalanceFragment()
             findNavController().navigate(action)
         } else {
-            Snackbar.make(view!!, R.string.config_error, LENGTH_LONG).show()
+            val res = if (result.authError) R.string.config_error_auth else 
R.string.config_error
+            Snackbar.make(view!!, res, LENGTH_LONG).show()
         }
         saveButton.visibility = VISIBLE
         progressBar.visibility = INVISIBLE
diff --git a/app/src/main/java/net/taler/cashier/HttpHelper.kt 
b/app/src/main/java/net/taler/cashier/HttpHelper.kt
index fa23bb3..6b9b1fa 100644
--- a/app/src/main/java/net/taler/cashier/HttpHelper.kt
+++ b/app/src/main/java/net/taler/cashier/HttpHelper.kt
@@ -1,46 +1,76 @@
 package net.taler.cashier
 
+import android.util.Log
 import androidx.annotation.WorkerThread
 import okhttp3.Credentials
 import okhttp3.MediaType
 import okhttp3.OkHttpClient
 import okhttp3.Request
 import okhttp3.RequestBody
-import okhttp3.Response
+import org.json.JSONObject
 
 object HttpHelper {
 
+    private val TAG = HttpHelper::class.java.simpleName
     private const val MIME_TYPE_JSON = "application/json"
 
     @WorkerThread
-    fun makeJsonGetRequest(url: String, config: Config): Response {
+    fun makeJsonGetRequest(url: String, config: Config): HttpJsonResult {
         val request = Request.Builder()
             .addHeader("Accept", MIME_TYPE_JSON)
             .url(url)
             .get()
             .build()
-        return getHttpClient(config.username, config.password)
+        val response = try {
+            getHttpClient(config.username, config.password)
             .newCall(request)
             .execute()
+        } catch (e: Exception) {
+            Log.e(TAG, "Error retrieving $url", e)
+            return HttpJsonResult.Error(500)
+        }
+        return if (response.code() == 200 && response.body() != null) {
+            val jsonObject = JSONObject(response.body()!!.string())
+            HttpJsonResult.Success(jsonObject)
+        } else {
+            Log.e(TAG, "Received status ${response.code()} from $url expected 
200")
+            HttpJsonResult.Error(response.code())
+        }
     }
 
     private val MEDIA_TYPE_JSON = MediaType.parse("$MIME_TYPE_JSON; 
charset=utf-8")
 
     @WorkerThread
-    fun makeJsonPostRequest(url: String, body: String, config: Config): 
Response {
+    fun makeJsonPostRequest(url: String, body: String, config: Config): 
HttpJsonResult {
         val request = Request.Builder()
             .addHeader("Accept", MIME_TYPE_JSON)
             .url(url)
             .post(RequestBody.create(MEDIA_TYPE_JSON, body))
             .build()
-        return getHttpClient(config.username, config.password)
-            .newCall(request)
-            .execute()
+        val response = try {
+            getHttpClient(config.username, config.password)
+                .newCall(request)
+                .execute()
+        } catch (e: Exception) {
+            Log.e(TAG, "Error retrieving $url", e)
+            return HttpJsonResult.Error(500)
+        }
+        return if (response.code() == 200 && response.body() != null) {
+            val jsonObject = JSONObject(response.body()!!.string())
+            HttpJsonResult.Success(jsonObject)
+        } else {
+            Log.e(TAG, "Received status ${response.code()} from $url expected 
200")
+            HttpJsonResult.Error(response.code())
+        }
     }
 
     private fun getHttpClient(username: String, password: String) =
         OkHttpClient.Builder().authenticator { _, response ->
             val credential = Credentials.basic(username, password)
+            if (credential == response.request().header("Authorization")) {
+                // If we already failed with these credentials, don't retry
+                return@authenticator null
+            }
             response
                 .request()
                 .newBuilder()
@@ -49,3 +79,8 @@ object HttpHelper {
         }.build()
 
 }
+
+sealed class HttpJsonResult {
+    class Error(val statusCode: Int) : HttpJsonResult()
+    class Success(val json: JSONObject) : HttpJsonResult()
+}
diff --git a/app/src/main/java/net/taler/cashier/MainViewModel.kt 
b/app/src/main/java/net/taler/cashier/MainViewModel.kt
index 9218122..b3b1184 100644
--- a/app/src/main/java/net/taler/cashier/MainViewModel.kt
+++ b/app/src/main/java/net/taler/cashier/MainViewModel.kt
@@ -2,7 +2,6 @@ package net.taler.cashier
 
 import android.annotation.SuppressLint
 import android.app.Application
-import android.graphics.Bitmap
 import android.util.Log
 import androidx.annotation.UiThread
 import androidx.annotation.WorkerThread
@@ -17,9 +16,9 @@ import androidx.security.crypto.MasterKeys
 import androidx.security.crypto.MasterKeys.AES256_GCM_SPEC
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
+import net.taler.cashier.Amount.Companion.fromStringSigned
 import net.taler.cashier.HttpHelper.makeJsonGetRequest
-import net.taler.cashier.HttpHelper.makeJsonPostRequest
-import org.json.JSONObject
+import net.taler.cashier.withdraw.WithdrawManager
 
 private val TAG = MainViewModel::class.java.simpleName
 
@@ -44,7 +43,8 @@ class MainViewModel(private val app: Application) : 
AndroidViewModel(app) {
         password = prefs.getString(PREF_KEY_PASSWORD, "")!!
     )
 
-    private var currency: String = prefs.getString(PREF_KEY_PASSWORD, 
"TESTKUDOS")!!
+    internal var currency: String = prefs.getString(PREF_KEY_CURRENCY, 
"TESTKUDOS")!!
+        private set
 
     private val mConfigResult = MutableLiveData<ConfigResult>()
     val configResult: LiveData<ConfigResult> = mConfigResult
@@ -52,11 +52,8 @@ class MainViewModel(private val app: Application) : 
AndroidViewModel(app) {
     private val mBalance = MutableLiveData<String>()
     val balance: LiveData<String> = mBalance
 
-    private val mWithdrawAmount = MutableLiveData<String>()
-    val withdrawAmount: LiveData<String> = mWithdrawAmount
-
-    private val mWithdrawResult = MutableLiveData<WithdrawResult>()
-    val withdrawResult: LiveData<WithdrawResult> = mWithdrawResult
+    internal val withdrawManager =
+        WithdrawManager(app, this)
 
     fun hasConfig() = config.bankUrl.isNotEmpty()
             && config.username.isNotEmpty()
@@ -70,32 +67,24 @@ class MainViewModel(private val app: Application) : 
AndroidViewModel(app) {
     fun checkAndSaveConfig(config: Config) {
         mConfigResult.value = null
         viewModelScope.launch(Dispatchers.IO) {
-            // TODO use to-be-done final URL
-            val url = "${config.bankUrl}/history?delta=1&direction=both"
+            val url = "${config.bankUrl}/accounts/${config.username}/balance"
             Log.d(TAG, "Checking config: $url")
-            val response = try {
-                makeJsonGetRequest(url, config)
-            } catch (e: Exception) {
-                Log.e(TAG, "Error fetching $url", e)
-                mConfigResult.postValue(ConfigResult(false))
-                return@launch
-            }
-            if (response.code() == 200 && response.body() != null) {
-                // get and save currency
-                val json = JSONObject(response.body()!!.string())
-                val history = json.getJSONArray("data")
-                val amount = history.getJSONObject(0).getString("amount")
-                currency = Amount.fromString(amount).currency
-                prefs.edit().putString(PREF_KEY_CURRENCY, currency).apply()
-
-                // save config
-                saveConfig(config)
-                mConfigResult.postValue(ConfigResult(true))
-            } else {
-                Log.e(TAG, "Error fetching $url - Response: 
${response.code()}")
-                val authError = false
-                mConfigResult.postValue(ConfigResult(false, authError))
+            val result = when (val response = makeJsonGetRequest(url, config)) 
{
+                is HttpJsonResult.Success -> {
+                    val balance = response.json.getString("balance")
+                    val amount = fromStringSigned(balance)!!
+                    currency = amount.currency
+                    prefs.edit().putString(PREF_KEY_CURRENCY, currency).apply()
+                    // save config
+                    saveConfig(config)
+                    ConfigResult(true)
+                }
+                is HttpJsonResult.Error -> {
+                    val authError = response.statusCode == 401
+                    ConfigResult(false, authError)
+                }
             }
+            mConfigResult.postValue(result)
         }
     }
 
@@ -112,23 +101,17 @@ class MainViewModel(private val app: Application) : 
AndroidViewModel(app) {
 
     fun getBalance() = viewModelScope.launch(Dispatchers.IO) {
         check(hasConfig()) { "No config to get balance" }
-        val url = "${config.bankUrl}/history?delta=50&direction=both"
-        val response = try {
-            makeJsonGetRequest(url, config)
-        } catch (e: Exception) {
-            Log.e(TAG, "Error fetching $url", e)
-            mBalance.postValue(app.getString(R.string.balance_error))
-            return@launch
-        }
-        val result = if (response.code() == 200 && response.body() != null) {
-            // TODO get real amount here once API is available
-            val json = JSONObject(response.body()!!.string())
-            val amountStr = 
json.getJSONArray("data").getJSONObject(0).getString("amount")
-            val amount = Amount.fromString(amountStr)
-            "${amount.amount} ${amount.currency}"
-        } else {
-            Log.e(TAG, "Error fetching $url - Response: ${response.code()}")
-            app.getString(R.string.balance_error)
+        val url = "${config.bankUrl}/accounts/${config.username}/balance"
+        Log.d(TAG, "Checking balance at $url")
+        val result = when (val response = makeJsonGetRequest(url, config)) {
+            is HttpJsonResult.Success -> {
+                val balance = response.json.getString("balance")
+                val amount = fromStringSigned(balance)!!
+                "${amount.amount} ${amount.currency}"
+            }
+            is HttpJsonResult.Error -> {
+                app.getString(R.string.balance_error)
+            }
         }
         mBalance.postValue(result)
     }
@@ -137,40 +120,6 @@ class MainViewModel(private val app: Application) : 
AndroidViewModel(app) {
         saveConfig(config.copy(password = ""))
     }
 
-    @UiThread
-    fun hasSufficientBalance(amount: Int): Boolean {
-        val balanceStr = balance.value?.split(' ')?.get(0)
-        if (balanceStr == app.getString(R.string.balance_error)) return false
-        val balanceDouble = balanceStr?.toDouble() ?: 0.0
-        return amount <= balanceDouble
-    }
-
-    @UiThread
-    fun withdraw(amount: Int) {
-        check(amount > 0) { "Withdraw amount was <= 0" }
-        mWithdrawResult.value = null
-        mWithdrawAmount.value = "$amount $currency"
-        viewModelScope.launch(Dispatchers.IO) {
-            val url = "${config.bankUrl}/taler-bank-api/testing/withdraw-uri"
-            val body = JSONObject(mapOf("amount" to 
"${currency}:${amount}")).toString()
-            val response = try {
-                makeJsonPostRequest(url, body, config)
-            } catch (e: Exception) {
-                Log.e(TAG, "Error fetching $url", e)
-                val errorStr = app.getString(R.string.withdraw_error_fetch)
-                return@launch 
mWithdrawResult.postValue(WithdrawResult.Error(errorStr))
-            }
-            if (response.code() == 200 && response.body() != null) {
-                val json = JSONObject(response.body()!!.string())
-                Log.e("TEST", json.toString(4))
-            } else {
-                Log.e(TAG, "Error fetching $url - Response: 
${response.code()}")
-                val errorStr = app.getString(R.string.withdraw_error_fetch)
-                mWithdrawResult.postValue(WithdrawResult.Error(errorStr))
-            }
-        }
-    }
-
 }
 
 data class Config(
@@ -180,9 +129,3 @@ data class Config(
 )
 
 class ConfigResult(val success: Boolean, val authError: Boolean = false)
-
-sealed class WithdrawResult {
-    object InsufficientBalance : WithdrawResult()
-    class Error(val msg: String) : WithdrawResult()
-    class Success(val talerUri: String, val qrCode: Bitmap) : WithdrawResult()
-}
diff --git a/app/src/main/java/net/taler/cashier/TransactionFragment.kt 
b/app/src/main/java/net/taler/cashier/TransactionFragment.kt
deleted file mode 100644
index 13e6005..0000000
--- a/app/src/main/java/net/taler/cashier/TransactionFragment.kt
+++ /dev/null
@@ -1,86 +0,0 @@
-package net.taler.cashier
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.View.INVISIBLE
-import android.view.View.VISIBLE
-import android.view.ViewGroup
-import androidx.core.content.ContextCompat.getColor
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.activityViewModels
-import androidx.lifecycle.Observer
-import androidx.navigation.fragment.findNavController
-import kotlinx.android.synthetic.main.fragment_transaction.*
-import net.taler.cashier.WithdrawResult.Error
-import net.taler.cashier.WithdrawResult.InsufficientBalance
-import net.taler.cashier.WithdrawResult.Success
-
-class TransactionFragment : Fragment() {
-
-    private val viewModel: MainViewModel by activityViewModels()
-    private val nfcManager = NfcManager()
-
-    override fun onCreateView(
-        inflater: LayoutInflater, container: ViewGroup?,
-        savedInstanceState: Bundle?
-    ): View? {
-        return inflater.inflate(R.layout.fragment_transaction, container, 
false)
-    }
-
-    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        viewModel.withdrawAmount.observe(viewLifecycleOwner, Observer { amount 
->
-            amountView.text = amount
-        })
-        viewModel.withdrawResult.observe(viewLifecycleOwner, Observer { result 
->
-            if (result != null) {
-                progressBar.animate()
-                    .alpha(0f)
-                    .withEndAction { progressBar.visibility = INVISIBLE }
-                    .setDuration(750)
-                    .start()
-            }
-            when (result) {
-                is InsufficientBalance -> {
-                    val c = getColor(requireContext(), 
R.color.design_default_color_error)
-                    introView.setTextColor(c)
-                    introView.text = 
getString(R.string.withdraw_error_insufficient_balance)
-                }
-                is Error -> {
-                    val c = getColor(requireContext(), 
R.color.design_default_color_error)
-                    introView.setTextColor(c)
-                    introView.text = result.msg
-                }
-                is Success -> {
-                    // start NFC
-                    nfcManager.setTagString(result.talerUri)
-                    NfcManager.start(requireActivity(), nfcManager)
-                    // show QR code
-                    qrCodeView.alpha = 0f
-                    qrCodeView.animate()
-                        .alpha(1f)
-                        .withStartAction {
-                            qrCodeView.visibility = VISIBLE
-                            qrCodeView.setImageBitmap(result.qrCode)
-                        }
-                        .setDuration(750)
-                        .start()
-                }
-            }
-        })
-        cancelButton.setOnClickListener { findNavController().popBackStack() }
-    }
-
-    override fun onStart() {
-        super.onStart()
-        if (viewModel.withdrawResult.value is Success) {
-            NfcManager.start(requireActivity(), nfcManager)
-        }
-    }
-
-    override fun onStop() {
-        super.onStop()
-        NfcManager.stop(requireActivity())
-    }
-
-}
diff --git a/app/src/main/java/net/taler/cashier/Utils.kt 
b/app/src/main/java/net/taler/cashier/Utils.kt
index edcdad2..5c9cf5c 100644
--- a/app/src/main/java/net/taler/cashier/Utils.kt
+++ b/app/src/main/java/net/taler/cashier/Utils.kt
@@ -1,5 +1,9 @@
 package net.taler.cashier
 
+import android.view.View
+import android.view.View.INVISIBLE
+import android.view.View.VISIBLE
+
 object Utils {
 
     private const val HEX_CHARS = "0123456789ABCDEF"
@@ -34,3 +38,20 @@ object Utils {
     }
 
 }
+
+fun View.fadeIn(endAction: () -> Unit = {}) {
+    alpha = 0f
+    visibility = VISIBLE
+    animate().alpha(1f).withEndAction {
+        endAction.invoke()
+    }.start()
+}
+
+fun View.fadeOut(endAction: () -> Unit = {}) {
+    if (visibility == INVISIBLE) return
+    animate().alpha(0f).withEndAction {
+        visibility = INVISIBLE
+        alpha = 1f
+        endAction.invoke()
+    }.start()
+}
diff --git a/app/src/main/java/net/taler/cashier/withdraw/ErrorFragment.kt 
b/app/src/main/java/net/taler/cashier/withdraw/ErrorFragment.kt
new file mode 100644
index 0000000..acb0ea3
--- /dev/null
+++ b/app/src/main/java/net/taler/cashier/withdraw/ErrorFragment.kt
@@ -0,0 +1,33 @@
+package net.taler.cashier.withdraw
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.fragment.findNavController
+import kotlinx.android.synthetic.main.fragment_success.*
+import net.taler.cashier.MainViewModel
+import net.taler.cashier.R
+
+class ErrorFragment : Fragment() {
+
+    private val viewModel: MainViewModel by activityViewModels()
+    private val withdrawManager by lazy { viewModel.withdrawManager }
+
+    override fun onCreateView(
+        inflater: LayoutInflater, container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View? {
+        return inflater.inflate(R.layout.fragment_error, container, false)
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        withdrawManager.resetState()
+        backButton.setOnClickListener {
+            findNavController().popBackStack()
+        }
+    }
+
+}
diff --git a/app/src/main/java/net/taler/cashier/NfcManager.kt 
b/app/src/main/java/net/taler/cashier/withdraw/NfcManager.kt
similarity index 96%
rename from app/src/main/java/net/taler/cashier/NfcManager.kt
rename to app/src/main/java/net/taler/cashier/withdraw/NfcManager.kt
index 5473eb0..3aa1caa 100644
--- a/app/src/main/java/net/taler/cashier/NfcManager.kt
+++ b/app/src/main/java/net/taler/cashier/withdraw/NfcManager.kt
@@ -1,4 +1,4 @@
-package net.taler.cashier
+package net.taler.cashier.withdraw
 
 import android.app.Activity
 import android.content.Context
@@ -23,14 +23,18 @@ class NfcManager : NfcAdapter.ReaderCallback {
          * Enables NFC reader mode. Don't forget to call [stop] afterwards.
          */
         fun start(activity: Activity, nfcManager: NfcManager) {
-            getNfcAdapter(activity)?.enableReaderMode(activity, nfcManager, 
nfcManager.flags, null)
+            getNfcAdapter(
+                activity
+            )?.enableReaderMode(activity, nfcManager, nfcManager.flags, null)
         }
 
         /**
          * Disables NFC reader mode. Call after [start].
          */
         fun stop(activity: Activity) {
-            getNfcAdapter(activity)?.disableReaderMode(activity)
+            getNfcAdapter(
+                activity
+            )?.disableReaderMode(activity)
         }
 
         private fun getNfcAdapter(context: Context): NfcAdapter? {
diff --git a/app/src/main/java/net/taler/cashier/QrCodeManager.kt 
b/app/src/main/java/net/taler/cashier/withdraw/QrCodeManager.kt
similarity index 95%
rename from app/src/main/java/net/taler/cashier/QrCodeManager.kt
rename to app/src/main/java/net/taler/cashier/withdraw/QrCodeManager.kt
index 61060a2..a5a5496 100644
--- a/app/src/main/java/net/taler/cashier/QrCodeManager.kt
+++ b/app/src/main/java/net/taler/cashier/withdraw/QrCodeManager.kt
@@ -1,4 +1,4 @@
-package net.taler.cashier
+package net.taler.cashier.withdraw
 
 import android.graphics.Bitmap
 import android.graphics.Bitmap.Config.RGB_565
diff --git a/app/src/main/java/net/taler/cashier/withdraw/SuccessFragment.kt 
b/app/src/main/java/net/taler/cashier/withdraw/SuccessFragment.kt
new file mode 100644
index 0000000..53459f9
--- /dev/null
+++ b/app/src/main/java/net/taler/cashier/withdraw/SuccessFragment.kt
@@ -0,0 +1,33 @@
+package net.taler.cashier.withdraw
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.fragment.findNavController
+import kotlinx.android.synthetic.main.fragment_success.*
+import net.taler.cashier.MainViewModel
+import net.taler.cashier.R
+
+class SuccessFragment : Fragment() {
+
+    private val viewModel: MainViewModel by activityViewModels()
+    private val withdrawManager by lazy { viewModel.withdrawManager }
+
+    override fun onCreateView(
+        inflater: LayoutInflater, container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View? {
+        return inflater.inflate(R.layout.fragment_success, container, false)
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        withdrawManager.resetState()
+        backButton.setOnClickListener {
+            findNavController().popBackStack()
+        }
+    }
+
+}
diff --git 
a/app/src/main/java/net/taler/cashier/withdraw/TransactionFragment.kt 
b/app/src/main/java/net/taler/cashier/withdraw/TransactionFragment.kt
new file mode 100644
index 0000000..f359cbc
--- /dev/null
+++ b/app/src/main/java/net/taler/cashier/withdraw/TransactionFragment.kt
@@ -0,0 +1,146 @@
+package net.taler.cashier.withdraw
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.INVISIBLE
+import android.view.View.VISIBLE
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat.getColor
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Observer
+import androidx.navigation.fragment.findNavController
+import kotlinx.android.synthetic.main.fragment_transaction.*
+import net.taler.cashier.MainViewModel
+import net.taler.cashier.R
+import net.taler.cashier.fadeIn
+import net.taler.cashier.fadeOut
+import 
net.taler.cashier.withdraw.TransactionFragmentDirections.Companion.actionTransactionFragmentToErrorFragment
+import 
net.taler.cashier.withdraw.TransactionFragmentDirections.Companion.actionTransactionFragmentToSuccessFragment
+import net.taler.cashier.withdraw.WithdrawResult.Error
+import net.taler.cashier.withdraw.WithdrawResult.InsufficientBalance
+import net.taler.cashier.withdraw.WithdrawResult.Success
+
+class TransactionFragment : Fragment() {
+
+    private val viewModel: MainViewModel by activityViewModels()
+    private val withdrawManager by lazy { viewModel.withdrawManager }
+    private val nfcManager = NfcManager()
+
+    override fun onCreateView(
+        inflater: LayoutInflater, container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View? {
+        return inflater.inflate(R.layout.fragment_transaction, container, 
false)
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        withdrawManager.withdrawAmount.observe(viewLifecycleOwner, Observer { 
amount ->
+            amountView.text = amount
+        })
+        withdrawManager.withdrawResult.observe(viewLifecycleOwner, Observer { 
result ->
+            onWithdrawResultReceived(result)
+        })
+        withdrawManager.withdrawStatus.observe(viewLifecycleOwner, Observer { 
status ->
+            onWithdrawStatusChanged(status)
+        })
+        cancelButton.setOnClickListener {
+            findNavController().popBackStack()
+        }
+    }
+
+    override fun onStart() {
+        super.onStart()
+        if (withdrawManager.withdrawResult.value is Success) {
+            NfcManager.start(
+                requireActivity(),
+                nfcManager
+            )
+        }
+    }
+
+    override fun onStop() {
+        super.onStop()
+        NfcManager.stop(requireActivity())
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        if (!requireActivity().isChangingConfigurations) {
+            withdrawManager.abort()
+        }
+    }
+
+    private fun onWithdrawResultReceived(result: WithdrawResult?) {
+        if (result != null) {
+            progressBar.animate()
+                .alpha(0f)
+                .withEndAction { progressBar?.visibility = INVISIBLE }
+                .setDuration(750)
+                .start()
+        }
+        when (result) {
+            is InsufficientBalance -> {
+                val c = getColor(
+                    requireContext(),
+                    R.color.design_default_color_error
+                )
+                introView.setTextColor(c)
+                introView.text = 
getString(R.string.withdraw_error_insufficient_balance)
+            }
+            is Error -> {
+                val c = getColor(
+                    requireContext(),
+                    R.color.design_default_color_error
+                )
+                introView.setTextColor(c)
+                introView.text = result.msg
+            }
+            is Success -> {
+                // start NFC
+                nfcManager.setTagString(result.talerUri)
+                NfcManager.start(
+                    requireActivity(),
+                    nfcManager
+                )
+                // show QR code
+                qrCodeView.alpha = 0f
+                qrCodeView.animate()
+                    .alpha(1f)
+                    .withStartAction {
+                        qrCodeView.visibility = VISIBLE
+                        qrCodeView.setImageBitmap(result.qrCode)
+                    }
+                    .setDuration(750)
+                    .start()
+            }
+        }
+    }
+
+    private fun onWithdrawStatusChanged(status: WithdrawStatus?): Any = when 
(status) {
+        is WithdrawStatus.SelectionDone -> {
+            qrCodeView.fadeOut()
+            progressBar.fadeIn()
+            introView.fadeOut {
+                introView.text = getString(R.string.transaction_intro_scanned)
+                introView.fadeIn()
+            }
+        }
+        is WithdrawStatus.Confirmed -> 
actionTransactionFragmentToSuccessFragment().let {
+            findNavController().navigate(it)
+        }
+        is WithdrawStatus.Aborted -> onError()
+        is WithdrawStatus.Error -> onError()
+        null -> {
+            // no-op
+        }
+    }
+
+    private fun onError() {
+        actionTransactionFragmentToErrorFragment().let {
+            findNavController().navigate(it)
+        }
+    }
+
+}
diff --git a/app/src/main/java/net/taler/cashier/WithdrawFragment.kt 
b/app/src/main/java/net/taler/cashier/withdraw/WithdrawFragment.kt
similarity index 74%
rename from app/src/main/java/net/taler/cashier/WithdrawFragment.kt
rename to app/src/main/java/net/taler/cashier/withdraw/WithdrawFragment.kt
index 75fd99f..d4b4c8d 100644
--- a/app/src/main/java/net/taler/cashier/WithdrawFragment.kt
+++ b/app/src/main/java/net/taler/cashier/withdraw/WithdrawFragment.kt
@@ -1,4 +1,4 @@
-package net.taler.cashier
+package net.taler.cashier.withdraw
 
 import android.os.Bundle
 import android.view.LayoutInflater
@@ -9,10 +9,13 @@ import androidx.fragment.app.Fragment
 import androidx.fragment.app.activityViewModels
 import androidx.navigation.fragment.findNavController
 import kotlinx.android.synthetic.main.fragment_withdraw.*
+import net.taler.cashier.MainViewModel
+import net.taler.cashier.R
 
 class WithdrawFragment : Fragment() {
 
     private val viewModel: MainViewModel by activityViewModels()
+    private val withdrawManager by lazy { viewModel.withdrawManager }
 
     override fun onCreateView(
         inflater: LayoutInflater, container: ViewGroup?,
@@ -27,19 +30,26 @@ class WithdrawFragment : Fragment() {
         button20.setOnClickListener { onAmountButtonPressed(20) }
         button50.setOnClickListener { onAmountButtonPressed(50) }
 
+        if (savedInstanceState != null) {
+            
amountView.editText!!.setText(savedInstanceState.getCharSequence("amount"))
+        }
         amountView.editText!!.setOnEditorActionListener { _, actionId, _ ->
             if (actionId == IME_ACTION_GO) {
                 onAmountConfirmed(getAmountFromView())
                 true
             } else false
         }
-        clearButton.setOnClickListener {
-            amountView.editText!!.text = null
-            amountView.error = null
-        }
         fab.setOnClickListener { onAmountConfirmed(getAmountFromView()) }
     }
 
+    override fun onSaveInstanceState(outState: Bundle) {
+        super.onSaveInstanceState(outState)
+        // for some reason automatic restore isn't working at the moment!?
+        amountView?.editText?.text.let {
+            outState.putCharSequence("amount", it)
+        }
+    }
+
     private fun onAmountButtonPressed(amount: Int) {
         amountView.editText!!.setText(amount.toString())
         amountView.error = null
@@ -54,12 +64,13 @@ class WithdrawFragment : Fragment() {
     private fun onAmountConfirmed(amount: Int) {
         if (amount <= 0) {
             amountView.error = getString(R.string.withdraw_error_zero)
-        } else if (!viewModel.hasSufficientBalance(amount)) {
+        } else if (!withdrawManager.hasSufficientBalance(amount)) {
             amountView.error = 
getString(R.string.withdraw_error_insufficient_balance)
         } else {
             amountView.error = null
-            viewModel.withdraw(amount)
-            
WithdrawFragmentDirections.actionWithdrawFragmentToTransactionFragment().let {
+            withdrawManager.withdraw(amount)
+            
WithdrawFragmentDirections.actionWithdrawFragmentToTransactionFragment()
+                .let {
                 findNavController().navigate(it)
             }
         }
diff --git a/app/src/main/java/net/taler/cashier/withdraw/WithdrawManager.kt 
b/app/src/main/java/net/taler/cashier/withdraw/WithdrawManager.kt
new file mode 100644
index 0000000..6ccf344
--- /dev/null
+++ b/app/src/main/java/net/taler/cashier/withdraw/WithdrawManager.kt
@@ -0,0 +1,184 @@
+package net.taler.cashier.withdraw
+
+import android.app.Application
+import android.graphics.Bitmap
+import android.os.CountDownTimer
+import android.util.Log
+import androidx.annotation.UiThread
+import androidx.annotation.WorkerThread
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.distinctUntilChanged
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import net.taler.cashier.HttpHelper.makeJsonGetRequest
+import net.taler.cashier.HttpHelper.makeJsonPostRequest
+import net.taler.cashier.HttpJsonResult.Error
+import net.taler.cashier.HttpJsonResult.Success
+import net.taler.cashier.MainViewModel
+import net.taler.cashier.R
+import org.json.JSONObject
+import java.util.concurrent.TimeUnit.MINUTES
+import java.util.concurrent.TimeUnit.SECONDS
+
+private val TAG = WithdrawManager::class.java.simpleName
+
+private val INTERVAL = SECONDS.toMillis(1)
+private val TIMEOUT = MINUTES.toMillis(2)
+
+class WithdrawManager(
+    private val app: Application,
+    private val viewModel: MainViewModel
+) {
+    private val scope
+        get() = viewModel.viewModelScope
+
+    private val config
+        get() = viewModel.config
+
+    private val currency
+        get() = viewModel.currency
+
+    private val mWithdrawAmount = MutableLiveData<String>()
+    val withdrawAmount: LiveData<String> = mWithdrawAmount
+
+    private val mWithdrawResult = MutableLiveData<WithdrawResult>()
+    val withdrawResult: LiveData<WithdrawResult> = mWithdrawResult
+
+    private val mWithdrawStatus = MutableLiveData<WithdrawStatus>()
+    val withdrawStatus: LiveData<WithdrawStatus> = 
mWithdrawStatus.distinctUntilChanged()
+
+    @UiThread
+    fun hasSufficientBalance(amount: Int): Boolean {
+        val balanceStr = viewModel.balance.value?.split(' ')?.get(0)
+        if (balanceStr == app.getString(R.string.balance_error)) return false
+        val balanceDouble = balanceStr?.toDouble() ?: 0.0
+        return amount <= balanceDouble
+    }
+
+    @UiThread
+    fun withdraw(amount: Int) {
+        check(amount > 0) { "Withdraw amount was <= 0" }
+        mWithdrawResult.value = null
+        mWithdrawAmount.value = "$amount $currency"
+        scope.launch(Dispatchers.IO) {
+            val url = 
"${config.bankUrl}/accounts/${config.username}/withdrawals"
+            Log.d(TAG, "Starting withdrawal at $url")
+            val body = JSONObject(mapOf("amount" to 
"${currency}:${amount}")).toString()
+            when (val result = makeJsonPostRequest(url, body, config)) {
+                is Success -> {
+                    val talerUri = result.json.getString("taler_withdraw_uri")
+                    val withdrawResult = WithdrawResult.Success(
+                        id = result.json.getString("withdrawal_id"),
+                        talerUri = talerUri,
+                        qrCode = QrCodeManager.makeQrCode(talerUri)
+                    )
+                    mWithdrawResult.postValue(withdrawResult)
+                    timer.start()
+                }
+                is Error -> {
+                    val errorStr = app.getString(R.string.withdraw_error_fetch)
+                    mWithdrawResult.postValue(WithdrawResult.Error(errorStr))
+                }
+            }
+        }
+    }
+
+    private val timer: CountDownTimer = object : CountDownTimer(TIMEOUT, 
INTERVAL) {
+        override fun onTick(millisUntilFinished: Long) {
+            val result = withdrawResult.value
+            if (result is WithdrawResult.Success) {
+                checkWithdrawStatus(result.id)
+            } else {
+                cancel()
+            }
+        }
+
+        override fun onFinish() {
+            abort()
+            mWithdrawStatus.postValue(WithdrawStatus.Error)
+            cancel()
+        }
+    }
+
+    private fun checkWithdrawStatus(withdrawalId: String) = 
scope.launch(Dispatchers.IO) {
+        val url = 
"${config.bankUrl}/accounts/${config.username}/withdrawals/${withdrawalId}"
+        Log.d(TAG, "Checking withdraw status at $url")
+        val response = makeJsonGetRequest(url, config)
+        if (response !is Success) return@launch  // ignore errors and continue 
trying
+        when {
+            response.json.getBoolean("aborted") -> {
+                mWithdrawStatus.postValue(WithdrawStatus.Aborted)
+                timer.cancel()
+            }
+            response.json.getBoolean("confirmation_done") -> {
+                mWithdrawStatus.postValue(WithdrawStatus.Confirmed)
+                viewModel.getBalance()
+                timer.cancel()
+            }
+            response.json.getBoolean("selection_done") -> {
+                if (mWithdrawStatus.value != WithdrawStatus.SelectionDone) {
+                    mWithdrawStatus.postValue(WithdrawStatus.SelectionDone)
+                    confirm(withdrawalId)
+                }
+            }
+        }
+    }
+
+    /**
+     * Aborts the last [withdrawResult], if it exists und there is no 
[withdrawStatus].
+     * Otherwise this is a no-op.
+     */
+    fun abort() {
+        val result = withdrawResult.value
+        val status = withdrawStatus.value
+        if (result is WithdrawResult.Success && status == null) {
+            timer.cancel()
+            abort(result.id)
+        }
+    }
+
+    private fun abort(withdrawalId: String) = scope.launch(Dispatchers.IO) {
+        val url = 
"${config.bankUrl}/accounts/${config.username}/withdrawals/${withdrawalId}/abort"
+        Log.d(TAG, "Aborting withdrawal at $url")
+        makeJsonPostRequest(url, "", config)
+    }
+
+    @WorkerThread
+    private fun confirm(withdrawalId: String) {
+        val url =
+            
"${config.bankUrl}/accounts/${config.username}/withdrawals/${withdrawalId}/confirm"
+        Log.d(TAG, "Confirming withdrawal at $url")
+        when (val result = makeJsonPostRequest(url, "", config)) {
+            is Success -> {
+                mWithdrawStatus.postValue(WithdrawStatus.Confirmed)
+            }
+            is Error -> {
+                Log.e(TAG, "Error confirming withdrawal. Status code: 
${result.statusCode}")
+                mWithdrawStatus.postValue(WithdrawStatus.Error)
+            }
+        }
+    }
+
+    @UiThread
+    fun resetState() {
+        mWithdrawAmount.value = null
+        mWithdrawResult.value = null
+        mWithdrawStatus.value = null
+    }
+
+}
+
+sealed class WithdrawResult {
+    object InsufficientBalance : WithdrawResult()
+    class Error(val msg: String) : WithdrawResult()
+    class Success(val id: String, val talerUri: String, val qrCode: Bitmap) : 
WithdrawResult()
+}
+
+sealed class WithdrawStatus {
+    object Error : WithdrawStatus()
+    object Aborted : WithdrawStatus()
+    object SelectionDone : WithdrawStatus()
+    object Confirmed : WithdrawStatus()
+}
diff --git a/app/src/main/res/drawable/ic_check_circle.xml 
b/app/src/main/res/drawable/ic_check_circle.xml
new file mode 100644
index 0000000..7fbbed1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_check_circle.xml
@@ -0,0 +1,11 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android";
+    android:width="24dp"
+    android:height="24dp"
+    android:alpha="0.56"
+    android:tint="@color/green"
+    android:viewportWidth="24.0"
+    android:viewportHeight="24.0">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 
10,-10S17.52,2 12,2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_error.xml 
b/app/src/main/res/drawable/ic_error.xml
new file mode 100644
index 0000000..b7e22a0
--- /dev/null
+++ b/app/src/main/res/drawable/ic_error.xml
@@ -0,0 +1,11 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android";
+    android:width="24dp"
+    android:height="24dp"
+    android:alpha="0.56"
+    android:tint="@color/red"
+    android:viewportWidth="24.0"
+    android:viewportHeight="24.0">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 
10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z" />
+</vector>
diff --git a/app/src/main/res/layout/fragment_transaction.xml 
b/app/src/main/res/layout-w550dp/fragment_transaction.xml
similarity index 75%
copy from app/src/main/res/layout/fragment_transaction.xml
copy to app/src/main/res/layout-w550dp/fragment_transaction.xml
index 3804595..0076f25 100644
--- a/app/src/main/res/layout/fragment_transaction.xml
+++ b/app/src/main/res/layout-w550dp/fragment_transaction.xml
@@ -4,7 +4,7 @@
     xmlns:tools="http://schemas.android.com/tools";
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    tools:context=".TransactionFragment">
+    tools:context=".withdraw.TransactionFragment">
 
     <TextView
         android:id="@+id/introView"
@@ -14,7 +14,7 @@
         android:gravity="center_horizontal"
         android:text="@string/transaction_intro"
         android:textAppearance="@style/TextAppearance.AppCompat.Medium"
-        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintEnd_toStartOf="@+id/guideline"
         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintTop_toTopOf="parent" />
 
@@ -25,11 +25,18 @@
         android:layout_margin="32dp"
         android:gravity="center_horizontal"
         android:textAppearance="@style/TextAppearance.AppCompat.Headline"
-        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintEnd_toStartOf="@+id/guideline"
         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintTop_toBottomOf="@+id/introView"
         tools:text="50 KUDOS" />
 
+    <androidx.constraintlayout.widget.Guideline
+        android:id="@+id/guideline"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:orientation="vertical"
+        app:layout_constraintGuide_percent="0.5" />
+
     <ImageView
         android:id="@+id/qrCodeView"
         android:layout_width="256dp"
@@ -37,9 +44,10 @@
         android:layout_margin="32dp"
         android:keepScreenOn="true"
         android:visibility="invisible"
+        app:layout_constraintBottom_toBottomOf="parent"
         app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@+id/amountView"
+        app:layout_constraintStart_toStartOf="@+id/guideline"
+        app:layout_constraintTop_toTopOf="parent"
         tools:ignore="ContentDescription"
         tools:src="@tools:sample/avatars"
         tools:visibility="visible" />
@@ -56,13 +64,13 @@
 
     <Button
         android:id="@+id/cancelButton"
-        style="@style/Widget.AppCompat.Button.Colored"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
-        android:layout_margin="32dp"
-        android:text="@string/transaction_cancel"
+        android:layout_margin="16dp"
+        android:backgroundTint="@color/red"
+        android:text="@string/transaction_abort"
         app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintEnd_toStartOf="@+id/guideline"
         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintTop_toBottomOf="@+id/qrCodeView"
         app:layout_constraintVertical_bias="1.0" />
diff --git a/app/src/main/res/layout/activity_main.xml 
b/app/src/main/res/layout/activity_main.xml
index 413d578..bf7f543 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -11,12 +11,11 @@
         android:layout_height="wrap_content"
         android:theme="@style/AppTheme.AppBarOverlay">
 
-        <androidx.appcompat.widget.Toolbar
+        <com.google.android.material.appbar.MaterialToolbar
             android:id="@+id/toolbar"
             android:layout_width="match_parent"
             android:layout_height="?attr/actionBarSize"
-            android:background="?attr/colorPrimary"
-            app:popupTheme="@style/AppTheme.PopupOverlay" />
+            android:background="?attr/colorPrimary" />
 
     </com.google.android.material.appbar.AppBarLayout>
 
diff --git a/app/src/main/res/layout/fragment_balance.xml 
b/app/src/main/res/layout/fragment_balance.xml
index 348deb6..dd376d0 100644
--- a/app/src/main/res/layout/fragment_balance.xml
+++ b/app/src/main/res/layout/fragment_balance.xml
@@ -50,8 +50,9 @@
         android:layout_height="wrap_content"
         android:layout_gravity="bottom|end"
         android:layout_margin="@dimen/default_margin"
-        android:tint="@color/design_default_color_surface"
+        android:visibility="invisible"
         app:srcCompat="@drawable/ic_withdraw"
-        app:useCompatPadding="true" />
+        app:useCompatPadding="true"
+        tools:visibility="visible" />
 
 </androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/app/src/main/res/layout/fragment_config.xml 
b/app/src/main/res/layout/fragment_config.xml
index 49855d2..0da0c14 100644
--- a/app/src/main/res/layout/fragment_config.xml
+++ b/app/src/main/res/layout/fragment_config.xml
@@ -7,11 +7,11 @@
 
     <com.google.android.material.textfield.TextInputLayout
         android:id="@+id/urlView"
+        
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
         android:layout_width="0dp"
         android:layout_height="wrap_content"
         android:layout_margin="@dimen/default_margin"
         android:hint="@string/config_bank_url"
-        app:boxBackgroundMode="outline"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintTop_toTopOf="parent">
@@ -25,6 +25,7 @@
 
     <com.google.android.material.textfield.TextInputLayout
         android:id="@+id/usernameView"
+        
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
         android:layout_width="0dp"
         android:layout_height="wrap_content"
         android:layout_margin="@dimen/default_margin"
@@ -43,6 +44,7 @@
 
     <com.google.android.material.textfield.TextInputLayout
         android:id="@+id/passwordView"
+        
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
         android:layout_width="0dp"
         android:layout_height="wrap_content"
         android:layout_margin="@dimen/default_margin"
@@ -62,7 +64,6 @@
 
     <Button
         android:id="@+id/saveButton"
-        style="@style/Widget.AppCompat.Button.Colored"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:layout_margin="@dimen/default_margin"
diff --git a/app/src/main/res/layout/fragment_error.xml 
b/app/src/main/res/layout/fragment_error.xml
new file mode 100644
index 0000000..143a92d
--- /dev/null
+++ b/app/src/main/res/layout/fragment_error.xml
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout 
xmlns:android="http://schemas.android.com/apk/res/android";
+    xmlns:app="http://schemas.android.com/apk/res-auto";
+    xmlns:tools="http://schemas.android.com/tools";
+    android:id="@+id/frameLayout"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".withdraw.SuccessFragment">
+
+    <ImageView
+        android:id="@+id/imageView"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:layout_margin="32dp"
+        android:src="@drawable/ic_error"
+        app:layout_constraintBottom_toTopOf="@+id/textView"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintHorizontal_bias="0.5"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        tools:ignore="ContentDescription" />
+
+    <androidx.appcompat.widget.AppCompatTextView
+        android:id="@+id/textView"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:layout_margin="32dp"
+        android:text="@string/transaction_error"
+        android:textAlignment="center"
+        android:textColor="@color/red"
+        app:autoSizeMaxTextSize="42sp"
+        app:autoSizeTextType="uniform"
+        app:layout_constraintBottom_toTopOf="@+id/backButton"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintHorizontal_bias="0.5"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/imageView" />
+
+    <Button
+        android:id="@+id/backButton"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_margin="16dp"
+        android:text="@string/transaction_button_back"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_success.xml 
b/app/src/main/res/layout/fragment_success.xml
new file mode 100644
index 0000000..e59072e
--- /dev/null
+++ b/app/src/main/res/layout/fragment_success.xml
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout 
xmlns:android="http://schemas.android.com/apk/res/android";
+    xmlns:app="http://schemas.android.com/apk/res-auto";
+    xmlns:tools="http://schemas.android.com/tools";
+    android:id="@+id/frameLayout"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".withdraw.SuccessFragment">
+
+    <ImageView
+        android:id="@+id/imageView"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:layout_margin="32dp"
+        android:src="@drawable/ic_check_circle"
+        app:layout_constraintBottom_toTopOf="@+id/textView"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintHorizontal_bias="0.5"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        tools:ignore="ContentDescription" />
+
+    <androidx.appcompat.widget.AppCompatTextView
+        android:id="@+id/textView"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:layout_margin="32dp"
+        android:text="@string/transaction_success"
+        android:textAlignment="center"
+        android:textColor="@color/green"
+        app:autoSizeMaxTextSize="42sp"
+        app:autoSizeTextType="uniform"
+        app:layout_constraintBottom_toTopOf="@+id/backButton"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintHorizontal_bias="0.5"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/imageView" />
+
+    <Button
+        android:id="@+id/backButton"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_margin="16dp"
+        android:text="@string/transaction_button_back"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_transaction.xml 
b/app/src/main/res/layout/fragment_transaction.xml
index 3804595..8083fcf 100644
--- a/app/src/main/res/layout/fragment_transaction.xml
+++ b/app/src/main/res/layout/fragment_transaction.xml
@@ -4,7 +4,7 @@
     xmlns:tools="http://schemas.android.com/tools";
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    tools:context=".TransactionFragment">
+    tools:context=".withdraw.TransactionFragment">
 
     <TextView
         android:id="@+id/introView"
@@ -56,11 +56,11 @@
 
     <Button
         android:id="@+id/cancelButton"
-        style="@style/Widget.AppCompat.Button.Colored"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
-        android:layout_margin="32dp"
-        android:text="@string/transaction_cancel"
+        android:layout_margin="16dp"
+        android:backgroundTint="@color/red"
+        android:text="@string/transaction_abort"
         app:layout_constraintBottom_toBottomOf="parent"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
diff --git a/app/src/main/res/layout/fragment_withdraw.xml 
b/app/src/main/res/layout/fragment_withdraw.xml
index 4a98660..e038978 100644
--- a/app/src/main/res/layout/fragment_withdraw.xml
+++ b/app/src/main/res/layout/fragment_withdraw.xml
@@ -4,7 +4,7 @@
     xmlns:tools="http://schemas.android.com/tools";
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    tools:context=".WithdrawFragment">
+    tools:context=".withdraw.WithdrawFragment">
 
     <androidx.constraintlayout.widget.ConstraintLayout
         android:layout_width="match_parent"
@@ -23,10 +23,14 @@
 
         <com.google.android.material.textfield.TextInputLayout
             android:id="@+id/amountView"
+            
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_margin="32dp"
-            app:boxBackgroundMode="outline"
+            android:hint="@string/withdraw_input_amount"
+            app:endIconDrawable="@drawable/ic_clear"
+            app:endIconMode="clear_text"
+            app:endIconTint="?attr/colorControlNormal"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintStart_toStartOf="parent"
             app:layout_constraintTop_toBottomOf="@+id/button5">
@@ -41,17 +45,6 @@
 
         </com.google.android.material.textfield.TextInputLayout>
 
-        <androidx.appcompat.widget.AppCompatImageButton
-            android:id="@+id/clearButton"
-            android:layout_width="48dp"
-            android:layout_height="48dp"
-            android:background="?attr/selectableItemBackgroundBorderless"
-            android:contentDescription="@string/withdraw_clear"
-            android:src="@drawable/ic_clear"
-            app:layout_constraintEnd_toEndOf="@+id/amountView"
-            app:layout_constraintTop_toTopOf="@+id/amountView"
-            app:tint="?attr/colorControlNormal" />
-
         <Button
             android:id="@+id/button5"
             style="@style/AmountButton"
diff --git a/app/src/main/res/navigation/nav_graph.xml 
b/app/src/main/res/navigation/nav_graph.xml
index 930d13f..5d7dd78 100644
--- a/app/src/main/res/navigation/nav_graph.xml
+++ b/app/src/main/res/navigation/nav_graph.xml
@@ -32,7 +32,7 @@
 
     <fragment
         android:id="@+id/withdrawFragment"
-        android:name="net.taler.cashier.WithdrawFragment"
+        android:name="net.taler.cashier.withdraw.WithdrawFragment"
         android:label="fragment_withdraw"
         tools:layout="@layout/fragment_withdraw">
         <action
@@ -45,9 +45,30 @@
 
     <fragment
         android:id="@+id/transactionFragment"
-        android:name="net.taler.cashier.TransactionFragment"
+        android:name="net.taler.cashier.withdraw.TransactionFragment"
         android:label="fragment_transaction"
-        tools:layout="@layout/fragment_transaction" />
+        tools:layout="@layout/fragment_transaction">
+        <action
+            android:id="@+id/action_transactionFragment_to_successFragment"
+            app:destination="@id/successFragment"
+            app:launchSingleTop="true"
+            app:popUpTo="@+id/balanceFragment" />
+        <action
+            android:id="@+id/action_transactionFragment_to_errorFragment"
+            app:destination="@id/errorFragment"
+            app:launchSingleTop="true"
+            app:popUpTo="@+id/withdrawFragment" />
+    </fragment>
+
+    <fragment
+        android:id="@+id/successFragment"
+        android:name="net.taler.cashier.withdraw.SuccessFragment"
+        tools:layout="@layout/fragment_success" />
+
+    <fragment
+        android:id="@+id/errorFragment"
+        android:name="net.taler.cashier.withdraw.ErrorFragment"
+        tools:layout="@layout/fragment_error" />
 
     <action
         android:id="@+id/action_global_configFragment"
diff --git a/app/src/main/res/values/colors.xml 
b/app/src/main/res/values/colors.xml
index 9f6551e..d9acb88 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -3,4 +3,7 @@
     <color name="colorPrimary">#1565C0</color>
     <color name="colorPrimaryDark">#6A1B9A</color>
     <color name="colorAccent">#D81B60</color>
+
+    <color name="green">#4CAF50</color>
+    <color name="red">#D32F2F</color>
 </resources>
diff --git a/app/src/main/res/values/strings.xml 
b/app/src/main/res/values/strings.xml
index db80499..ab15c09 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -8,12 +8,14 @@
     <string name="config_bank_url_error">The address in invalid.</string>
     <string name="config_username_error">Please enter your username</string>
     <string name="config_error">Error retrieving configuration</string>
+    <string name="config_error_auth">Invalid username or password</string>
 
     <string name="balance_current_label">Current Balance:</string>
     <string name="balance_error">ERROR</string>
     <string name="action_reconfigure">Reconfigure</string>
     <string name="action_lock">Lock</string>
 
+    <string name="withdraw_input_amount">Amount</string>
     <string name="withdraw_clear">clear amount</string>
     <string name="withdraw_into">How much to cash in?</string>
     <string name="withdraw_error_zero">Enter positive amount</string>
@@ -21,6 +23,10 @@
     <string name="withdraw_error_fetch">Error communicating with bank</string>
 
     <string name="transaction_intro">Scan code or use NFC\nwith the Taler 
wallet app\nto get</string>
-    <string name="transaction_cancel">Cancel</string>
+    <string name="transaction_intro_scanned">Waiting for confirmation…</string>
+    <string name="transaction_abort">Abort</string>
+    <string name="transaction_success">Transaction successful</string>
+    <string name="transaction_error">Transaction error</string>
+    <string name="transaction_button_back">Go back</string>
 
 </resources>
diff --git a/app/src/main/res/values/styles.xml 
b/app/src/main/res/values/styles.xml
index e3ef098..667fe05 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -1,10 +1,16 @@
 <resources>
 
-    <style name="AppTheme" parent="Theme.AppCompat.DayNight.DarkActionBar">
+    <style name="AppTheme" 
parent="Theme.MaterialComponents.DayNight.DarkActionBar">
         <item name="colorPrimary">@color/colorPrimary</item>
+        <item 
name="colorOnPrimary">@color/design_default_color_background</item>
         <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
+        <item name="colorSecondary">@color/colorAccent</item>
+        <item 
name="colorOnSecondary">@color/design_default_color_background</item>
         <item name="colorAccent">@color/colorAccent</item>
-        <!-- Customize your theme here. -->
+
+        <!-- https://stackoverflow.com/q/59081672/4856311 -->
+        <item 
name="actionOverflowMenuStyle">@style/Widget.MaterialComponents.PopupMenu.Overflow
+        </item>
     </style>
 
     <style name="AppTheme.NoActionBar">
@@ -12,11 +18,9 @@
         <item name="windowNoTitle">true</item>
     </style>
 
-    <style name="AppTheme.AppBarOverlay" 
parent="ThemeOverlay.AppCompat.Dark.ActionBar" />
-
-    <style name="AppTheme.PopupOverlay" 
parent="ThemeOverlay.AppCompat.DayNight" />
+    <style name="AppTheme.AppBarOverlay" 
parent="ThemeOverlay.MaterialComponents.Dark.ActionBar" />
 
-    <style name="AmountButton" parent="Widget.AppCompat.Button.Colored">
+    <style name="AmountButton" parent="Widget.MaterialComponents.Button">
         <item name="android:minWidth">48dp</item>
         <item name="android:layout_marginStart">@dimen/default_margin</item>
         <item name="android:layout_marginEnd">@dimen/default_margin</item>
diff --git a/build.gradle b/build.gradle
index a212d44..0e297f6 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,6 +1,6 @@
 buildscript {
     ext.kotlin_version = '1.3.61'
-    ext.nav_version = '2.2.0'
+    ext.nav_version = '2.2.1'
 
     repositories {
         google()

-- 
To stop receiving notification emails like this one, please contact
address@hidden.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]