diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..dcaf22cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.lock +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release +pubspec.lock diff --git a/.metadata b/.metadata new file mode 100644 index 00000000..fd70cabc --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 77d935af4db863f6abd0b9c31c7e6df2a13de57b + channel: stable + +project_type: app diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 00000000..61b6c4de --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,29 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..6f568019 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 00000000..39d034b4 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,86 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new FileNotFoundException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + + +android { + compileSdkVersion 33 + // compileSdkVersion flutter.compileSdkVersion + // compileSdkVersion localProperties.getProperty('flutter.compileSdkVersion').toInteger() + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.guardianlabs.keyper" + minSdkVersion 21 + //minSdkVersion localProperties.getProperty('flutter.minSdkVersion').toInteger() + targetSdkVersion flutter.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['storePassword'] + } + } + buildTypes { + release { + signingConfig signingConfigs.release + } + } + +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..4d269b7a --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..37f65618 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/guardian_network/MainActivity.kt b/android/app/src/main/kotlin/com/example/guardian_network/MainActivity.kt new file mode 100644 index 00000000..8abf5f08 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/guardian_network/MainActivity.kt @@ -0,0 +1,11 @@ +package com.guardianlabs.keyper + +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugins.GeneratedPluginRegistrant + +class MainActivity: FlutterFragmentActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + GeneratedPluginRegistrant.registerWith(flutterEngine) + } +} diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 00000000..e98fec1d Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 00000000..c5e9b473 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-hdpi/splash.png b/android/app/src/main/res/drawable-night-hdpi/splash.png new file mode 100644 index 00000000..e98fec1d Binary files /dev/null and b/android/app/src/main/res/drawable-night-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-mdpi/splash.png b/android/app/src/main/res/drawable-night-mdpi/splash.png new file mode 100644 index 00000000..c5e9b473 Binary files /dev/null and b/android/app/src/main/res/drawable-night-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-v21/background.png b/android/app/src/main/res/drawable-night-v21/background.png new file mode 100644 index 00000000..9d334f88 Binary files /dev/null and b/android/app/src/main/res/drawable-night-v21/background.png differ diff --git a/android/app/src/main/res/drawable-night-v21/launch_background.xml b/android/app/src/main/res/drawable-night-v21/launch_background.xml new file mode 100644 index 00000000..3fe6b2e8 --- /dev/null +++ b/android/app/src/main/res/drawable-night-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-night-xhdpi/splash.png b/android/app/src/main/res/drawable-night-xhdpi/splash.png new file mode 100644 index 00000000..7753cfef Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxhdpi/splash.png b/android/app/src/main/res/drawable-night-xxhdpi/splash.png new file mode 100644 index 00000000..89d3f0d5 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/splash.png new file mode 100644 index 00000000..5a324e5e Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night/background.png b/android/app/src/main/res/drawable-night/background.png new file mode 100644 index 00000000..9d334f88 Binary files /dev/null and b/android/app/src/main/res/drawable-night/background.png differ diff --git a/android/app/src/main/res/drawable-night/launch_background.xml b/android/app/src/main/res/drawable-night/launch_background.xml new file mode 100644 index 00000000..3fe6b2e8 --- /dev/null +++ b/android/app/src/main/res/drawable-night/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 00000000..9d334f88 Binary files /dev/null and b/android/app/src/main/res/drawable-v21/background.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..3fe6b2e8 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 00000000..7753cfef Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 00000000..89d3f0d5 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 00000000..5a324e5e Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png new file mode 100644 index 00000000..9d334f88 Binary files /dev/null and b/android/app/src/main/res/drawable/background.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..3fe6b2e8 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..5fe44c25 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..5fccbb63 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..c22db52a Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..870c3885 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..b893948e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..318ee10f --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..a286cd30 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..4d269b7a --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 00000000..4256f917 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.6.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:4.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..94adc3a3 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..bc6a58af --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 00000000..44e62bcf --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,11 @@ +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/assets/fonts/Poppins-Medium.ttf b/assets/fonts/Poppins-Medium.ttf new file mode 100644 index 00000000..6bcdcc27 Binary files /dev/null and b/assets/fonts/Poppins-Medium.ttf differ diff --git a/assets/fonts/Poppins-Regular.ttf b/assets/fonts/Poppins-Regular.ttf new file mode 100644 index 00000000..9f0c71b7 Binary files /dev/null and b/assets/fonts/Poppins-Regular.ttf differ diff --git a/assets/fonts/Poppins-SemiBold.ttf b/assets/fonts/Poppins-SemiBold.ttf new file mode 100644 index 00000000..74c726e3 Binary files /dev/null and b/assets/fonts/Poppins-SemiBold.ttf differ diff --git a/assets/fonts/SourceSansPro-Regular.ttf b/assets/fonts/SourceSansPro-Regular.ttf new file mode 100644 index 00000000..98e85797 Binary files /dev/null and b/assets/fonts/SourceSansPro-Regular.ttf differ diff --git a/assets/fonts/SourceSansPro-SemiBold.ttf b/assets/fonts/SourceSansPro-SemiBold.ttf new file mode 100644 index 00000000..99dcc81f Binary files /dev/null and b/assets/fonts/SourceSansPro-SemiBold.ttf differ diff --git a/assets/icons/biometric_logon_v1.svg b/assets/icons/biometric_logon_v1.svg new file mode 100644 index 00000000..b50e242e --- /dev/null +++ b/assets/icons/biometric_logon_v1.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/icons/fiduciaries_v1.svg b/assets/icons/fiduciaries_v1.svg new file mode 100644 index 00000000..55aad60a --- /dev/null +++ b/assets/icons/fiduciaries_v1.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/logo.svg b/assets/icons/logo.svg new file mode 100644 index 00000000..34a47734 --- /dev/null +++ b/assets/icons/logo.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/navbar_bell_selected_v1.svg b/assets/icons/navbar_bell_selected_v1.svg new file mode 100644 index 00000000..e4a9a4d4 --- /dev/null +++ b/assets/icons/navbar_bell_selected_v1.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/navbar_bell_v1.svg b/assets/icons/navbar_bell_v1.svg new file mode 100644 index 00000000..eabf5b88 --- /dev/null +++ b/assets/icons/navbar_bell_v1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/navbar_home_selected_v1.svg b/assets/icons/navbar_home_selected_v1.svg new file mode 100644 index 00000000..584e611a --- /dev/null +++ b/assets/icons/navbar_home_selected_v1.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/navbar_home_v1.svg b/assets/icons/navbar_home_v1.svg new file mode 100644 index 00000000..227a9fc3 --- /dev/null +++ b/assets/icons/navbar_home_v1.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/navbar_key_selected_v1.svg b/assets/icons/navbar_key_selected_v1.svg new file mode 100644 index 00000000..6e033088 --- /dev/null +++ b/assets/icons/navbar_key_selected_v1.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/navbar_key_v1.svg b/assets/icons/navbar_key_v1.svg new file mode 100644 index 00000000..6df073d5 --- /dev/null +++ b/assets/icons/navbar_key_v1.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/navbar_shield_selected_v1.svg b/assets/icons/navbar_shield_selected_v1.svg new file mode 100644 index 00000000..bd9a50a9 --- /dev/null +++ b/assets/icons/navbar_shield_selected_v1.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/navbar_shield_v1.svg b/assets/icons/navbar_shield_v1.svg new file mode 100644 index 00000000..4934ded5 --- /dev/null +++ b/assets/icons/navbar_shield_v1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/no_secrets_v1.svg b/assets/icons/no_secrets_v1.svg new file mode 100644 index 00000000..41a5f528 --- /dev/null +++ b/assets/icons/no_secrets_v1.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/owner_v1.svg b/assets/icons/owner_v1.svg new file mode 100644 index 00000000..0813a4a1 --- /dev/null +++ b/assets/icons/owner_v1.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/passcode_v1.svg b/assets/icons/passcode_v1.svg new file mode 100644 index 00000000..8392ad6a --- /dev/null +++ b/assets/icons/passcode_v1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/remove_group_v1.svg b/assets/icons/remove_group_v1.svg new file mode 100644 index 00000000..904cadb0 --- /dev/null +++ b/assets/icons/remove_group_v1.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/scan_qr_v1.svg b/assets/icons/scan_qr_v1.svg new file mode 100644 index 00000000..a9068a3b --- /dev/null +++ b/assets/icons/scan_qr_v1.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/secret_restoration_v1.svg b/assets/icons/secret_restoration_v1.svg new file mode 100644 index 00000000..be5b82a0 --- /dev/null +++ b/assets/icons/secret_restoration_v1.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/secret_v1.svg b/assets/icons/secret_v1.svg new file mode 100644 index 00000000..8fa5fc4c --- /dev/null +++ b/assets/icons/secret_v1.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/shard_owner_v1.svg b/assets/icons/shard_owner_v1.svg new file mode 100644 index 00000000..39629deb --- /dev/null +++ b/assets/icons/shard_owner_v1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/share_v1.svg b/assets/icons/share_v1.svg new file mode 100644 index 00000000..92630f9c --- /dev/null +++ b/assets/icons/share_v1.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/shield_v1.svg b/assets/icons/shield_v1.svg new file mode 100644 index 00000000..ccc4c9f7 --- /dev/null +++ b/assets/icons/shield_v1.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/split_and_share_v1.svg b/assets/icons/split_and_share_v1.svg new file mode 100644 index 00000000..30161362 --- /dev/null +++ b/assets/icons/split_and_share_v1.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/your_devices_v1.svg b/assets/icons/your_devices_v1.svg new file mode 100644 index 00000000..f40d26ac --- /dev/null +++ b/assets/icons/your_devices_v1.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/images/1.5x/logo.png b/assets/images/1.5x/logo.png new file mode 100644 index 00000000..719c8c9f Binary files /dev/null and b/assets/images/1.5x/logo.png differ diff --git a/assets/images/2.0x/logo.png b/assets/images/2.0x/logo.png new file mode 100644 index 00000000..90fa3104 Binary files /dev/null and b/assets/images/2.0x/logo.png differ diff --git a/assets/images/3.0x/logo.png b/assets/images/3.0x/logo.png new file mode 100644 index 00000000..042a68ba Binary files /dev/null and b/assets/images/3.0x/logo.png differ diff --git a/assets/images/4.0x/logo.png b/assets/images/4.0x/logo.png new file mode 100644 index 00000000..0f574f99 Binary files /dev/null and b/assets/images/4.0x/logo.png differ diff --git a/assets/images/intro_1.svg b/assets/images/intro_1.svg new file mode 100644 index 00000000..2c6a2863 --- /dev/null +++ b/assets/images/intro_1.svg @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/intro_2.svg b/assets/images/intro_2.svg new file mode 100644 index 00000000..1129a718 --- /dev/null +++ b/assets/images/intro_2.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/intro_3.svg b/assets/images/intro_3.svg new file mode 100644 index 00000000..c895e597 --- /dev/null +++ b/assets/images/intro_3.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/intro_4.svg b/assets/images/intro_4.svg new file mode 100644 index 00000000..b93d3c7b --- /dev/null +++ b/assets/images/intro_4.svg @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/intro_biometrics.svg b/assets/images/intro_biometrics.svg new file mode 100644 index 00000000..d7703602 --- /dev/null +++ b/assets/images/intro_biometrics.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 00000000..149eaae4 Binary files /dev/null and b/assets/images/logo.png differ diff --git a/assets/images/logo1024.png b/assets/images/logo1024.png new file mode 100644 index 00000000..28c681cf Binary files /dev/null and b/assets/images/logo1024.png differ diff --git a/assets/images/logo512.png b/assets/images/logo512.png new file mode 100644 index 00000000..f56c06fd Binary files /dev/null and b/assets/images/logo512.png differ diff --git a/assets/images/logo_qr.png b/assets/images/logo_qr.png new file mode 100644 index 00000000..918b8bea Binary files /dev/null and b/assets/images/logo_qr.png differ diff --git a/assets/images/secret_mask.svg b/assets/images/secret_mask.svg new file mode 100644 index 00000000..60f99b60 --- /dev/null +++ b/assets/images/secret_mask.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 00000000..7a7f9873 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..9625e105 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 11.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..592ceee8 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..592ceee8 --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..34a351f1 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,481 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1300; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.guardianlabs.keyper; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.guardianlabs.keyper; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.guardianlabs.keyper; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} \ No newline at end of file diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..c87d15a3 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..70693e4a --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d36b1fab --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..03ff520d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..6e0dec06 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..76a40714 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..ba493ca3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..6bc23a7a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..7288ca78 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..10cc13e7 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..76a40714 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..5430b280 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..a09a5f26 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..a09a5f26 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..95ca4148 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..01105055 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..ee608a5d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..537f326f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/BrandingImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/BrandingImage.imageset/Contents.json new file mode 100644 index 00000000..12712275 --- /dev/null +++ b/ios/Runner/Assets.xcassets/BrandingImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "BrandingImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "BrandingImage@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "BrandingImage@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json new file mode 100644 index 00000000..fa313278 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json @@ -0,0 +1,52 @@ +{ + "images" : [ + { + "filename" : "background.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "darkbackground.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png new file mode 100644 index 00000000..9d334f88 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png new file mode 100644 index 00000000..9d334f88 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/darkbackground.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..f3387d4a --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,56 @@ +{ + "images" : [ + { + "filename" : "LaunchImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "LaunchImageDark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "LaunchImage@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "LaunchImageDark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "LaunchImage@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "LaunchImageDark@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000..c5e9b473 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..7753cfef Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..89d3f0d5 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png new file mode 100644 index 00000000..c5e9b473 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png new file mode 100644 index 00000000..7753cfef Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png new file mode 100644 index 00000000..89d3f0d5 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImageDark@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..902dc66f --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 00000000..dd8b0812 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,71 @@ + + + + + + NSLocalNetworkUsageDescription + Keyper dartshare service + NSBonjourServices + + _dartshare._udp + + NSCameraUsageDescription + Keyper uses QR codes to link guardian devices to the main device + NSLocationWhenInUseUsageDescription + This app uses a QR-generation library that may put your geodata in the image file with the QR code + NSPhotoLibraryUsageDescription + This app stores and loads QR codes + NSFaceIDUsageDescription + This app uses FaceID to protect your secrets + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Guardian Keyper + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Guardian Keyper + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + com.apple.developer.networking.multicast + + ITSAppUsesNonExemptEncryption + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + + + + + UIViewControllerBasedStatusBarAppearance + + UIStatusBarHidden + + UIRequiresFullScreen + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 00000000..d480072c --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,3 @@ +arb-dir: lib/src/localization +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 00000000..74189adb --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:flutter_native_splash/flutter_native_splash.dart'; + +import 'src/app.dart'; +import 'src/core/theme_data.dart'; +import 'src/core/di_container.dart'; +import 'src/core/model/core_model.dart'; +import 'src/core/service/platform_service.dart'; + +Future main() async { + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + statusBarColor: clIndigo900, + statusBarBrightness: Brightness.dark, + statusBarIconBrightness: Brightness.light, + )); + final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); + await SystemChrome.setPreferredOrientations( + [DeviceOrientation.portraitUp], + ); + // ACHTUNG! Due to a quirk in String.fromEnvironment: + // "is only guaranteed to work when invoked as const", so it MUST have + // defaultValue defined - otherwise, running the app in AndroidStudio + // with environment variables set through --dart-define doesn't work. + const sentryUrl = + kDebugMode ? '' : String.fromEnvironment('SENTRY_URL', defaultValue: ""); + const bsAddressV4 = bool.hasEnvironment("BS_V4") + ? String.fromEnvironment("BS_V4", defaultValue: "") + : null; + const bsAddressV6 = bool.hasEnvironment("BS_V6") + ? String.fromEnvironment("BS_V6", defaultValue: "") + : null; + await SentryFlutter.init( + (options) => options + ..dsn = sentryUrl + ..tracesSampleRate = 1.0, + appRunner: () async { + FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); + runApp(App( + diContainer: await DIContainer.bootstrap( + globals: const GlobalsModel( + bsAddressV4: bsAddressV4, + bsAddressV6: bsAddressV6, + ), + platformService: await PlatformService.bootstrap(), + ))); + FlutterNativeSplash.remove(); + }, + ); +} diff --git a/lib/src/app.dart b/lib/src/app.dart new file mode 100644 index 00000000..9046e930 --- /dev/null +++ b/lib/src/app.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +import 'core/theme_data.dart'; +import 'core/di_container.dart'; +import 'core/model/core_model.dart'; + +import 'start_view.dart'; +import 'home/home_view.dart'; +import 'guardian/guardian_controller.dart'; +import 'recovery_group/add_secret/add_secret_view.dart'; +import 'recovery_group/create_group/create_group_view.dart'; +import 'recovery_group/add_guardian/add_guardian_view.dart'; +import 'recovery_group/restore_group/restore_group_view.dart'; +import 'recovery_group/edit_group/recovery_group_edit_view.dart'; +import 'recovery_group/recovery_secret/recovery_secret_view.dart'; + +class App extends StatelessWidget { + final DIContainer diContainer; + + const App({super.key, required this.diContainer}); + + @override + Widget build(BuildContext context) => MultiProvider( + providers: [ + Provider.value(value: diContainer), + ChangeNotifierProvider( + create: (_) => GuardianController(diContainer: diContainer), + lazy: false, + ), + ], + child: MaterialApp( + title: 'Guardian Keyper', + debugShowCheckedModeBanner: false, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('en', '')], + theme: themeLight, + darkTheme: themeDark, + themeMode: ThemeMode.dark, + onGenerateRoute: _onGenerateRoute, + navigatorObservers: [SentryNavigatorObserver()], + ), + ); + + Route? _onGenerateRoute(RouteSettings routeSettings) => + MaterialPageRoute( + settings: routeSettings, + builder: (BuildContext context) { + switch (routeSettings.name) { + case HomeView.routeName: + return const HomeView(); + + case CreateGroupView.routeName: + return const CreateGroupView(); + + case RecoveryGroupEditView.routeName: + return RecoveryGroupEditView( + groupId: routeSettings.arguments as GroupId); + + case AddGuardianView.routeName: + return AddGuardianView( + groupId: routeSettings.arguments as GroupId); + + case RecoveryGroupAddSecretView.routeName: + return RecoveryGroupAddSecretView( + groupId: routeSettings.arguments as GroupId); + + case RecoveryGroupRecoverySecretView.routeName: + return RecoveryGroupRecoverySecretView( + groupId: routeSettings.arguments as GroupId); + + case RestoreGroupView.routeName: + return const RestoreGroupView(); + + default: + return const StartView(); + } + }, + ); +} diff --git a/lib/src/core/controller/base_controller.dart b/lib/src/core/controller/base_controller.dart new file mode 100644 index 00000000..c5a39cf5 --- /dev/null +++ b/lib/src/core/controller/base_controller.dart @@ -0,0 +1,9 @@ +import 'package:flutter/foundation.dart' show ChangeNotifier; + +import '../di_container.dart'; + +abstract class BaseController extends ChangeNotifier { + final DIContainer diContainer; + + BaseController({required this.diContainer}); +} diff --git a/lib/src/core/controller/page_controller.dart b/lib/src/core/controller/page_controller.dart new file mode 100644 index 00000000..4166b564 --- /dev/null +++ b/lib/src/core/controller/page_controller.dart @@ -0,0 +1,28 @@ +import 'base_controller.dart'; + +class PageController extends BaseController { + late int pagesCount; + int currentPage = 0; + + PageController({required super.diContainer, required this.pagesCount}); + + void gotoScreen(int value) { + if (value < 0 || value > pagesCount - 1) return; + currentPage = value; + notifyListeners(); + } + + void nextScreen([void _]) { + if (currentPage < pagesCount - 1) { + currentPage++; + notifyListeners(); + } + } + + void previousScreen([void _]) { + if (currentPage > 0) { + currentPage--; + notifyListeners(); + } + } +} diff --git a/lib/src/core/di_container.dart b/lib/src/core/di_container.dart new file mode 100644 index 00000000..676725e7 --- /dev/null +++ b/lib/src/core/di_container.dart @@ -0,0 +1,154 @@ +import 'package:event_bus/event_bus.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +export 'package:provider/provider.dart'; +export 'package:hive_flutter/hive_flutter.dart'; + +import 'model/core_model.dart'; +import 'service/network_service.dart'; +import 'service/platform_service.dart'; + +class DIContainer { + static final _eventBus = EventBus(); + final GlobalsModel globals; + final PlatformService platformService; + final NetworkService networkService; + final Box boxSettings; + final Box boxMessages; + final Box boxSecretShards; + final Box boxRecoveryGroup; + + const DIContainer({ + this.globals = const GlobalsModel(), + this.platformService = const PlatformService(), + required this.networkService, + required this.boxSettings, + required this.boxMessages, + required this.boxSecretShards, + required this.boxRecoveryGroup, + }); + + EventBus get eventBus => _eventBus; + + PeerId get myPeerId => PeerId(value: networkService.router.pubKey.data); + + static Future bootstrap({ + GlobalsModel globals = const GlobalsModel(), + PlatformService platformService = const PlatformService(), + }) async { + await initCrypto(); + final keyBunch = await platformService.getKeyBunch(generateKeyBunch); + final cipher = HiveAesCipher(keyBunch.encryptionAesKey); + await Hive.initFlutter('data'); + Hive + ..registerAdapter(MessageModelAdapter()) + ..registerAdapter(SettingsModelAdapter()) + ..registerAdapter(SecretShardModelAdapter()) + ..registerAdapter(RecoveryGroupModelAdapter()); + + final boxMessages = await Hive.openBox( + 'messages', + encryptionCipher: cipher, + ); + final boxSettings = await Hive.openBox( + 'settings', + encryptionCipher: cipher, + ); + final boxSecretShards = await Hive.openBox( + 'shards', + encryptionCipher: cipher, + ); + final boxRecoveryGroup = await Hive.openBox( + 'groups', + encryptionCipher: cipher, + ); + if (boxSettings.deviceName.isEmpty) { + boxSettings.deviceName = + await platformService.getDeviceName(keyBunch.encryptionPublicKey); + } + return DIContainer( + globals: globals, + boxSettings: boxSettings, + boxMessages: boxMessages, + boxSecretShards: boxSecretShards, + boxRecoveryGroup: boxRecoveryGroup, + platformService: platformService, + networkService: NetworkService.udp( + keyBunch: keyBunch, + bsAddressV4: boxSettings.isProxyEnabled ? globals.bsAddressV4 : null, + bsAddressV6: boxSettings.isProxyEnabled ? globals.bsAddressV6 : null, + ), + ); + } +} + +extension SettingsModelExt on Box { + SettingsModel readWhole() => get(0) ?? const SettingsModel(); + Future writeWhole(SettingsModel settings) => put(0, settings); + + String get passCode => readWhole().passCode; + String get deviceName => readWhole().deviceName; + bool get isBiometricsEnabled => readWhole().isBiometricsEnabled; + bool get isProxyEnabled => readWhole().isProxyEnabled; + + set passCode(String value) => + writeWhole(readWhole().copyWith(passCode: value)); + set deviceName(String value) => + writeWhole(readWhole().copyWith(deviceName: value)); + set isBiometricsEnabled(bool value) => + writeWhole(readWhole().copyWith(isBiometricsEnabled: value)); + set isProxyEnabled(bool value) => + writeWhole(readWhole().copyWith(isProxyEnabled: value)); +} + +class SettingsModelAdapter extends TypeAdapter { + @override + final int typeId = 1; + + @override + SettingsModel read(BinaryReader reader) => + SettingsModel.fromBytes(reader.readByteList()); + + @override + void write(BinaryWriter writer, SettingsModel obj) => + writer.writeByteList(obj.toBytes()); +} + +class SecretShardModelAdapter extends TypeAdapter { + @override + final int typeId = 10; + + @override + SecretShardModel read(BinaryReader reader) => + SecretShardModel.fromBytes(reader.readByteList()); + + @override + void write(BinaryWriter writer, SecretShardModel obj) => + writer.writeByteList(obj.toBytes()); +} + +class MessageModelAdapter extends TypeAdapter { + @override + final int typeId = 11; + + @override + MessageModel read(BinaryReader reader) => + MessageModel.fromBytes(reader.readByteList()); + + @override + void write(BinaryWriter writer, MessageModel obj) => + writer.writeByteList(obj.toBytes()); +} + +class RecoveryGroupModelAdapter extends TypeAdapter { + @override + final int typeId = 20; + + @override + RecoveryGroupModel read(BinaryReader reader) => + RecoveryGroupModel.fromBytes(reader.readByteList()); + + @override + void write(BinaryWriter writer, RecoveryGroupModel obj) => + writer.writeByteList(obj.toBytes()); +} diff --git a/lib/src/core/model/core_model.dart b/lib/src/core/model/core_model.dart new file mode 100644 index 00000000..88f094af --- /dev/null +++ b/lib/src/core/model/core_model.dart @@ -0,0 +1,61 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:equatable/equatable.dart'; +import 'package:messagepack/messagepack.dart'; + +import '../utils/random_utils.dart'; + +export 'dart:typed_data' show Uint8List; + +part 'recovery_group_model.dart'; +part 'secret_shard_model.dart'; +part 'event_bus_model.dart'; +part 'settings_model.dart'; +part 'qr_code_model.dart'; +part 'message_model.dart'; +part 'token_model.dart'; + +@immutable +class GlobalsModel { + final String? bsAddressV4; + final String? bsAddressV6; + final int maxNameLength; + final int minNameLength; + final int passCodeLength; + final int maxSecretLength; + final Duration pageChangeDuration; + final Duration retryNetworkTimeout; + final Duration snackBarDuration; + final Duration qrCodeExpires; + + const GlobalsModel({ + this.bsAddressV4, + this.bsAddressV6, + this.maxNameLength = 25, + this.minNameLength = 3, + this.passCodeLength = 6, + this.maxSecretLength = 256, + this.pageChangeDuration = const Duration(milliseconds: 250), + this.retryNetworkTimeout = const Duration(seconds: 3), + this.snackBarDuration = const Duration(seconds: 4), + this.qrCodeExpires = const Duration(days: 1), + }); +} + +@immutable +class KeyBunch { + final Uint8List encryptionPublicKey; + final Uint8List encryptionPrivateKey; + final Uint8List singPublicKey; + final Uint8List singPrivateKey; + final Uint8List encryptionAesKey; + + const KeyBunch({ + required this.encryptionPublicKey, + required this.encryptionPrivateKey, + required this.singPublicKey, + required this.singPrivateKey, + required this.encryptionAesKey, + }); +} diff --git a/lib/src/core/model/event_bus_model.dart b/lib/src/core/model/event_bus_model.dart new file mode 100644 index 00000000..531e599a --- /dev/null +++ b/lib/src/core/model/event_bus_model.dart @@ -0,0 +1,14 @@ +part of 'core_model.dart'; + +@immutable +abstract class EventBusEvent {} + +@immutable +abstract class EventBusCommand {} + +@immutable +class NewMessageProcessedEvent implements EventBusEvent { + final MessageModel message; + + const NewMessageProcessedEvent({required this.message}); +} diff --git a/lib/src/core/model/message_model.dart b/lib/src/core/model/message_model.dart new file mode 100644 index 00000000..773c4f7e --- /dev/null +++ b/lib/src/core/model/message_model.dart @@ -0,0 +1,124 @@ +part of 'core_model.dart'; + +enum OperationType { authPeer, getShard, setShard, takeOwnership } + +enum MessageStatus { started, processed, accepted, rejected, failed } + +@immutable +class MessageModel extends Equatable { + static const currentVersion = 1; + + final PeerId peerId; + final DateTime timestamp; + final OperationType type; + final MessageStatus status; + final Nonce nonce; + final SecretShardModel secretShard; + + MessageModel({ + PeerId? peerId, + DateTime? timestamp, + required this.type, + this.status = MessageStatus.started, + Nonce? nonce, + SecretShardModel? secretShard, + }) : peerId = peerId ?? PeerId.empty(), + timestamp = timestamp ?? DateTime.now(), + nonce = nonce ?? Nonce.empty(), + secretShard = secretShard ?? SecretShardModel(); + + @override + List get props => [ + type, + status, + peerId, + nonce, + secretShard, + ]; + + bool get isStarted => status == MessageStatus.started; + bool get isAccepted => status == MessageStatus.accepted; + bool get isProcessed => status == MessageStatus.processed; + bool get isRejected => status == MessageStatus.rejected; + bool get isFailed => status == MessageStatus.failed; + bool get isResolved => + status == MessageStatus.accepted || status == MessageStatus.rejected; + bool get hasResponse => + status == MessageStatus.accepted || + status == MessageStatus.rejected || + status == MessageStatus.failed; + + String get aKey { + switch (type) { + case OperationType.authPeer: + return nonce.asKey; + case OperationType.setShard: + return secretShard.groupId.asKey; + case OperationType.getShard: + return secretShard.groupId.asKey; + case OperationType.takeOwnership: + return nonce.asKey; + } + } + + factory MessageModel.fromBytes(Uint8List value, [PeerId? peerId]) { + final u = Unpacker(value); + if (u.unpackInt() != currentVersion) throw const FormatException(); + final timestamp = + DateTime.fromMillisecondsSinceEpoch(u.unpackInt()!, isUtc: true); + final type = OperationType.values[u.unpackInt()!]; + final status = MessageStatus.values[u.unpackInt()!]; + final nonce = Nonce(value: Uint8List.fromList(u.unpackBinary())); + final secretShardBytes = Uint8List.fromList(u.unpackBinary()); + final secretShard = secretShardBytes.isEmpty + ? SecretShardModel() + : SecretShardModel.fromBytes(secretShardBytes); + return MessageModel( + peerId: peerId ?? secretShard.ownerId, + timestamp: timestamp, + type: type, + status: status, + nonce: nonce, + secretShard: secretShard, + ); + } + + Uint8List toBytes() { + final p = Packer() + ..packInt(currentVersion) + ..packInt(timestamp.millisecondsSinceEpoch) + ..packInt(type.index) + ..packInt(status.index) + ..packBinary(nonce.value) + ..packBinary(secretShard.toBytes()); + return p.takeBytes(); + } + + MessageModel copyWith({ + PeerId? peerId, + OperationType? type, + MessageStatus? status, + Nonce? nonce, + SecretShardModel? secretShard, + }) => + MessageModel( + peerId: peerId ?? this.peerId, + timestamp: timestamp, + type: type ?? this.type, + status: status ?? this.status, + nonce: nonce ?? this.nonce, + secretShard: secretShard ?? this.secretShard, + ); + + MessageModel process(PeerId ownerId, [String? ownerName]) => copyWith( + peerId: ownerId, + status: MessageStatus.processed, + secretShard: secretShard.copyWith( + ownerId: ownerId, + ownerName: ownerName, + ), + ); + + MessageModel clearSecret() => + copyWith(secretShard: secretShard.copyWith(value: '')); +} diff --git a/lib/src/core/model/qr_code_model.dart b/lib/src/core/model/qr_code_model.dart new file mode 100644 index 00000000..a76107a9 --- /dev/null +++ b/lib/src/core/model/qr_code_model.dart @@ -0,0 +1,80 @@ +part of 'core_model.dart'; + +@immutable +class QRCode extends Equatable { + static const currentVersion = 1; + + static QRCode? tryParseBase64(String value) { + try { + return QRCode.fromBase64(value); + } catch (_) { + return null; + } + } + + final int version; + final int issuedAt; + final OperationType type; + final Nonce nonce; + final PeerId peerId; + final String peerName; + final List addresses; + + @override + List get props => [nonce, peerId, type, addresses]; + + DateTime get createdAt => DateTime.fromMillisecondsSinceEpoch(issuedAt); + + const QRCode({ + this.version = currentVersion, + required this.nonce, + required this.peerId, + required this.type, + required this.peerName, + this.issuedAt = 0, + this.addresses = const [], + }); + + factory QRCode.fromBytes(Uint8List bytes) { + final u = Unpacker(bytes); + final version = u.unpackInt()!; + switch (version) { + case 1: + return QRCode( + version: version, + issuedAt: u.unpackInt()!, + type: OperationType.values[u.unpackInt()!], + nonce: Nonce(value: Uint8List.fromList(u.unpackBinary())), + peerId: PeerId(value: Uint8List.fromList(u.unpackBinary())), + peerName: u.unpackString()!, + addresses: u + .unpackList() + .map((e) => Uint8List.fromList(e as List)) + .map((e) => InternetAddress.fromRawAddress(e)) + .toList(), + ); + default: + throw const FormatException(); + } + } + + factory QRCode.fromBase64(String value) => + QRCode.fromBytes(base64Decode(value)); + + Uint8List toBytes() { + final p = Packer() + ..packInt(currentVersion) + ..packInt(issuedAt) + ..packInt(type.index) + ..packBinary(nonce.value) + ..packBinary(peerId.value) + ..packString(peerName) + ..packListLength(addresses.length); + for (var e in addresses) { + p.packBinary(e.rawAddress); + } + return p.takeBytes(); + } + + String toBase64url() => base64UrlEncode(toBytes()); +} diff --git a/lib/src/core/model/recovery_group_model.dart b/lib/src/core/model/recovery_group_model.dart new file mode 100644 index 00000000..265221dd --- /dev/null +++ b/lib/src/core/model/recovery_group_model.dart @@ -0,0 +1,156 @@ +part of 'core_model.dart'; + +enum RecoveryGroupType { devices, fiduciaries } + +@immutable +class RecoveryGroupModel extends Equatable { + static const currentVersion = 1; + + final GroupId id; + final String name; + final RecoveryGroupType type; + final int maxSize; + final bool hasSecret; + final bool isRestoring; + final Map guardians; + + @override + List get props => [id]; + + int get currentSize => guardians.length; + + int get threshold => maxSize == 5 ? 3 : 2; + + int get neededMore => maxSize - currentSize; + + bool get hasMinimal => guardians.length >= threshold; + + bool get isFull => guardians.length == maxSize; + + bool get isNotFull => guardians.length < maxSize; + + bool get isMissed => guardians.length < threshold; + + bool get isNotRestoring => !isRestoring; + + bool get canAddGuardian => !hasSecret && !isRestoring && !isFull; + + bool get canAddSecret => !hasSecret && isFull && !isRestoring; + + bool get canRecoverSecret => hasSecret && hasMinimal; + + const RecoveryGroupModel({ + required this.id, + required this.name, + this.type = RecoveryGroupType.devices, + this.maxSize = 3, + this.hasSecret = false, + this.isRestoring = false, + this.guardians = const {}, + }) : assert(name != ''); + + factory RecoveryGroupModel.fromBytes(Uint8List value) { + final u = Unpacker(value); + if (u.unpackInt() != currentVersion) throw const FormatException(); + return RecoveryGroupModel( + id: GroupId(value: Uint8List.fromList(u.unpackBinary())), + name: u.unpackString()!, + type: RecoveryGroupType.values[u.unpackInt()!], + maxSize: u.unpackInt()!, + hasSecret: u.unpackBool()!, + isRestoring: u.unpackBool()!, + guardians: Map.fromEntries(u + .unpackList() + .map((e) => + GuardianModel.fromBytes(Uint8List.fromList(e as List))) + .map((g) => MapEntry(g.peerId, g))), + ); + } + + Uint8List toBytes() { + final guardiansAsBytes = guardians.values.map((e) => e.toBytes()); + final p = Packer() + ..packInt(currentVersion) + ..packBinary(id.value) + ..packString(name) + ..packInt(type.index) + ..packInt(maxSize) + ..packBool(hasSecret) + ..packBool(isRestoring) + ..packListLength(guardiansAsBytes.length); + guardiansAsBytes.forEach(p.packBinary); + return p.takeBytes(); + } + + RecoveryGroupModel addGuardian(GuardianModel guardian) { + if (isFull) throw RecoveryGroupGuardianLimitexhausted(); + if (guardians.containsKey(guardian.peerId)) { + throw RecoveryGroupGuardianAlreadyExists(); + } + return RecoveryGroupModel( + id: id, + name: name, + type: type, + maxSize: maxSize, + guardians: {...guardians, guardian.peerId: guardian}, + isRestoring: isFull ? false : isRestoring, + hasSecret: hasSecret, + ); + } + + RecoveryGroupModel completeGroup() => RecoveryGroupModel( + id: id, + name: name, + type: type, + maxSize: maxSize, + guardians: guardians, + isRestoring: isRestoring, + hasSecret: true, + ); +} + +class RecoveryGroupGuardianAlreadyExists implements Exception { + static const description = + 'Guardian with given name already exists in that group!'; +} + +class RecoveryGroupGuardianLimitexhausted implements Exception { + static const description = 'Guardian group size limit exhausted!'; +} + +@immutable +class GuardianModel extends Equatable { + static const currentVersion = 1; + + final PeerId peerId; + final String name; + final String tag; + + const GuardianModel({ + required this.peerId, + required this.name, + this.tag = '', + }) : assert(name != ''); + + @override + List get props => [peerId]; + + factory GuardianModel.fromBytes(Uint8List value) { + final u = Unpacker(value); + if (u.unpackInt() != currentVersion) throw const FormatException(); + return GuardianModel( + peerId: PeerId(value: Uint8List.fromList(u.unpackBinary())), + name: u.unpackString()!, + tag: u.unpackString()!, + ); + } + + Uint8List toBytes() { + final p = Packer() + ..packInt(currentVersion) + ..packBinary(peerId.value) + ..packString(name) + ..packString(tag); + return p.takeBytes(); + } +} diff --git a/lib/src/core/model/secret_shard_model.dart b/lib/src/core/model/secret_shard_model.dart new file mode 100644 index 00000000..9aa76b1c --- /dev/null +++ b/lib/src/core/model/secret_shard_model.dart @@ -0,0 +1,86 @@ +part of 'core_model.dart'; + +@immutable +class SecretShardModel extends Equatable { + static const currentVersion = 1; + + final String value; + final String ownerName; + final PeerId ownerId; + final String groupName; + final GroupId groupId; + final int groupSize; + final int groupThreshold; + + @override + List get props => [groupId, ownerId]; + + String get asKey => base64UrlEncode(groupId.value); + + bool get isEmpty => + ownerId.isEmpty && + groupId.isEmpty && + ownerName.isEmpty && + groupName.isEmpty && + value.isEmpty; + + SecretShardModel({ + this.value = '', + PeerId? ownerId, + this.ownerName = '', + GroupId? groupId, + this.groupName = '', + this.groupSize = 0, + this.groupThreshold = 0, + }) : ownerId = ownerId ?? PeerId.empty(), + groupId = groupId ?? GroupId.empty(); + + factory SecretShardModel.fromBytes(Uint8List bytes) { + final u = Unpacker(bytes); + if (u.unpackInt() != currentVersion) throw const FormatException(); + return SecretShardModel( + value: u.unpackString()!, + ownerId: PeerId(value: Uint8List.fromList(u.unpackBinary())), + ownerName: u.unpackString()!, + groupId: GroupId(value: Uint8List.fromList(u.unpackBinary())), + groupName: u.unpackString()!, + groupSize: u.unpackInt()!, + groupThreshold: u.unpackInt()!, + ); + } + + Uint8List toBytes() { + final p = Packer() + ..packInt(currentVersion) + ..packString(value) + ..packBinary(ownerId.value) + ..packString(ownerName) + ..packBinary(groupId.value) + ..packString(groupName) + ..packInt(groupSize) + ..packInt(groupThreshold); + return p.takeBytes(); + } + + SecretShardModel copyWith({ + String? value, + PeerId? ownerId, + String? ownerName, + GroupId? groupId, + String? groupName, + int? groupSize, + int? groupThreshold, + }) => + SecretShardModel( + value: value ?? this.value, + ownerId: ownerId ?? this.ownerId, + ownerName: ownerName ?? this.ownerName, + groupId: groupId ?? this.groupId, + groupName: groupName ?? this.groupName, + groupSize: groupSize ?? this.groupSize, + groupThreshold: groupThreshold ?? this.groupThreshold, + ); + + @override + String toString() => '$groupName of $ownerName}'; +} diff --git a/lib/src/core/model/settings_model.dart b/lib/src/core/model/settings_model.dart new file mode 100644 index 00000000..f4960506 --- /dev/null +++ b/lib/src/core/model/settings_model.dart @@ -0,0 +1,59 @@ +part of 'core_model.dart'; + +@immutable +class SettingsModel extends Equatable { + static const currentVersion = 1; + + final String passCode; + final String deviceName; + final bool isBiometricsEnabled; + final bool isProxyEnabled; + + const SettingsModel({ + this.passCode = '', + this.deviceName = '', + this.isBiometricsEnabled = false, + this.isProxyEnabled = true, + }); + factory SettingsModel.fromBytes(Uint8List bytes) { + final u = Unpacker(bytes); + if (u.unpackInt() != currentVersion) throw const FormatException(); + return SettingsModel( + passCode: u.unpackString()!, + deviceName: u.unpackString()!, + isBiometricsEnabled: u.unpackBool()!, + isProxyEnabled: u.unpackBool()!, + ); + } + + @override + List get props => [ + passCode, + deviceName, + isBiometricsEnabled, + isProxyEnabled, + ]; + + Uint8List toBytes() { + final p = Packer() + ..packInt(currentVersion) + ..packString(passCode) + ..packString(deviceName) + ..packBool(isBiometricsEnabled) + ..packBool(isProxyEnabled); + return p.takeBytes(); + } + + SettingsModel copyWith({ + String? passCode, + String? deviceName, + bool? isBiometricsEnabled, + bool? isProxyEnabled, + }) => + SettingsModel( + passCode: passCode ?? this.passCode, + deviceName: deviceName ?? this.deviceName, + isBiometricsEnabled: isBiometricsEnabled ?? this.isBiometricsEnabled, + isProxyEnabled: isProxyEnabled ?? this.isProxyEnabled, + ); +} diff --git a/lib/src/core/model/token_model.dart b/lib/src/core/model/token_model.dart new file mode 100644 index 00000000..19014993 --- /dev/null +++ b/lib/src/core/model/token_model.dart @@ -0,0 +1,75 @@ +part of 'core_model.dart'; + +@immutable +abstract class TokenBase extends Equatable { + final Uint8List value; + + @override + List get props => [value]; + + const TokenBase({required this.value}); + + bool get isEmpty => value.isEmpty; + + bool get isNotEmpty => value.isNotEmpty; + + int get length => value.length; + + String get asKey => base64UrlEncode(value); + + String get asHex { + final buffer = StringBuffer(); + for (int byte in value) { + buffer.write('${byte < 16 ? '0' : ''}${byte.toRadixString(16)}'); + } + return buffer.toString(); + } + + String toHexShort([int count = 12]) { + final s = asHex; + return s.length > count * 2 + ? '0x${s.substring(0, count)}...${s.substring(s.length - count)}' + : '0x$s'; + } +} + +@immutable +class PeerId extends TokenBase { + const PeerId({required super.value}) + : assert(value.length == 0 || value.length == 64); + + factory PeerId.empty() => PeerId(value: Uint8List(0)); +} + +@immutable +class GroupId extends TokenBase { + static const _length = 8; + + const GroupId({required super.value}) + : assert(value.length == 0 || value.length == _length); + + factory GroupId.aNew() => GroupId(value: getRandomBytes(_length)); + + factory GroupId.empty() => GroupId(value: Uint8List(0)); +} + +@immutable +class SecretId extends TokenBase { + static const _length = 8; + + const SecretId({required super.value}) + : assert(value.length == 0 || value.length == _length); + + factory SecretId.aNew() => SecretId(value: getRandomBytes(_length)); + + factory SecretId.empty() => SecretId(value: Uint8List(0)); +} + +@immutable +class Nonce extends TokenBase { + const Nonce({required super.value}); + + factory Nonce.aNew([length = 32]) => Nonce(value: getRandomBytes(length)); + + factory Nonce.empty() => Nonce(value: Uint8List(0)); +} diff --git a/lib/src/core/service/mdns_service.dart b/lib/src/core/service/mdns_service.dart new file mode 100644 index 00000000..89655b81 --- /dev/null +++ b/lib/src/core/service/mdns_service.dart @@ -0,0 +1,217 @@ +import 'dart:io'; +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/widgets.dart' hide Router; +import 'package:p2plib/p2plib.dart'; +import 'package:bonsoir/bonsoir.dart'; + +import '/src/core/utils/random_utils.dart'; + +class MdnsService extends TopicHandler with WidgetsBindingObserver { + static const _topicServiceDiscovery = 77; + + final MdnsBroadcastDiscovery mdnsBroadcastDiscover; + final _completers = {}; + final _peerEvents = StreamController<_MdnsEvent>.broadcast(); + + MdnsService({ + required Router router, + required this.mdnsBroadcastDiscover, + }) : super(router) { + WidgetsBinding.instance.addObserver(this); + mdnsBroadcastDiscover.discovery.stream.listen(_onDiscoveryEvent); + Future.microtask(mdnsBroadcastDiscover.run); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) async { + if (state == AppLifecycleState.resumed) { + await mdnsBroadcastDiscover.run(); + } else { + await mdnsBroadcastDiscover.stop(); + } + } + + @override + Uint64List topics() => Uint64List.fromList([_topicServiceDiscovery]); + + @override + void onMessage(Header header, Uint8List data, Peer peer) { + if (header.dstKey != router.pubKey) return; + try { + final packet = _ConfirmationPacket.deserialize(data); + switch (packet.type) { + case _MsgType.answer: + final token = RawToken(data: packet.id, len: packet.id.length); + final completer = _completers[token]; + if (completer != null) { + router.addPeer(header.srcKey, peer); + _peerEvents.add(_MdnsEvent(header.srcKey, peer)); + completer.complete(peer); + _completers.remove(token); + } + break; + + case _MsgType.request: + router.addPeer(header.srcKey, peer); + _peerEvents.add(_MdnsEvent(header.srcKey, peer)); + router.sendEncrypted( + _topicServiceDiscovery, + peer, + _ConfirmationPacket(_MsgType.answer, packet.id).serialize(), + dstKey: header.srcKey, + ); + break; + } + } catch (_) {} + } + + void _onDiscoveryEvent(_DiscoveryEvent event) async { + try { + final pubKeyData = base64Decode(event.pubKey); + if (pubKeyData.length != PubKey.length) return; + + final pubKey = PubKey(pubKeyData); + if (pubKey == router.pubKey) return; + + final ip = InternetAddress(event.ip); + final peer = ip.type == InternetAddressType.IPv4 + ? Peer(ip, router.connection.ipv4Port) + : Peer(ip, router.connection.ipv6Port); + + await _confirmPeer(pubKey.data, peer); + router.addPeer(pubKey, peer); + } catch (_) {} + } + + Future _confirmPeer( + Uint8List pubkey, + Peer peer, { + Duration timeout = const Duration(seconds: 5), + }) async { + final token = RawToken(data: getRandomBytes(64), len: 64); + _completers[token] = Completer(); + await router.sendEncrypted( + _topicServiceDiscovery, + peer, + _ConfirmationPacket(_MsgType.request, token.data).serialize(), + dstKey: PubKey(pubkey), + ); + return _completers[token]!.future.timeout( + timeout, + onTimeout: () { + _completers.remove(token); + throw TimeoutException('[BroadcastFinder] Request timeout'); + }, + ); + } +} + +class MdnsBroadcastDiscovery { + static const _serviceName = '_dartshare._udp'; + static const _port = 3264; + + final discovery = StreamController<_DiscoveryEvent>.broadcast(); + + late String deviceName; + late String pubKeyAttribute; + + BonsoirBroadcast? _broadcastService; + BonsoirDiscovery? _discoveryService; + + MdnsBroadcastDiscovery(PubKey pubKey) { + final encodedKey = base64Encode(pubKey.data); + deviceName = encodedKey.substring(0, 8); + pubKeyAttribute = encodedKey; + } + + Future run() async { + try { + await _startService(); + await _startDiscovery(); + } catch (_) {} + } + + Future stop() async { + try { + await _broadcastService?.stop(); + await _discoveryService?.stop(); + } catch (_) { + } finally { + _broadcastService = null; + _discoveryService = null; + } + } + + Future _startService() async { + _broadcastService ??= BonsoirBroadcast( + service: BonsoirService( + name: deviceName, + type: _serviceName, + port: _port, + attributes: {'pubKey': pubKeyAttribute}, + ), + ); + await _broadcastService!.ready; + await _broadcastService!.start(); + } + + Future _startDiscovery() async { + _discoveryService ??= BonsoirDiscovery(type: _serviceName); + await _discoveryService!.ready; + await _discoveryService!.start(); + _discoveryService!.eventStream!.listen((event) { + if (event.type == BonsoirDiscoveryEventType.discoveryServiceResolved && + event.service != null) { + final value = event.service!.toJson(); + final String? ip = value['service.ip']; + final String? pubKey = value['service.attributes']?['pubKey']; + if (ip == null || pubKey == null) return; + discovery.add(_DiscoveryEvent(ip, pubKey)); + } + }); + } +} + +enum _MsgType { request, answer } + +class _MdnsEvent { + final PubKey pubKey; + final Peer peer; + + const _MdnsEvent(this.pubKey, this.peer); +} + +class _DiscoveryEvent { + final String ip; + final String pubKey; + + const _DiscoveryEvent(this.ip, this.pubKey); +} + +class _ConfirmationPacket { + final _MsgType type; + final Uint8List id; + + const _ConfirmationPacket(this.type, this.id); + + factory _ConfirmationPacket.deserialize(Uint8List data) { + final bytes = ByteData.view(data.buffer); + if (bytes.lengthInBytes < 1) throw const FormatException(); + return _ConfirmationPacket( + _MsgType.values[bytes.getUint8(0)], + data.sublist(1), + ); + } + + Uint8List serialize() { + final bytes = ByteData(1 + id.length); + var offset = 0; + bytes.setUint8(offset++, type.index); + for (var byte in id) { + bytes.setInt8(offset++, byte); + } + return bytes.buffer.asUint8List(); + } +} diff --git a/lib/src/core/service/network_service.dart b/lib/src/core/service/network_service.dart new file mode 100644 index 00000000..d94ad946 --- /dev/null +++ b/lib/src/core/service/network_service.dart @@ -0,0 +1,175 @@ +import 'dart:io'; +import 'dart:async'; +import 'dart:typed_data'; +import 'package:flutter/widgets.dart' hide Router; +import 'package:p2plib/p2plib.dart'; + +import '../model/core_model.dart'; +import 'mdns_service.dart'; + +class NetworkService extends TopicHandler with WidgetsBindingObserver { + static const _topicOfRecoveryGroup = 100; + static const _topicOfGuardian = 101; + + final _guardianStreamController = StreamController.broadcast(); + final _recoveryGroupStreamController = + StreamController.broadcast(); + + NetworkService({ + required Router router, + bool useMdnsService = true, + }) : super(router) { + WidgetsBinding.instance.addObserver(this); + if (useMdnsService) { + MdnsService( + router: router, + mdnsBroadcastDiscover: MdnsBroadcastDiscovery(router.pubKey), + ); + } + Future.microtask(router.run); + } + + factory NetworkService.udp({ + required KeyBunch keyBunch, + String? bsAddressV4, + String? bsAddressV6, + int bsPort = 4349, + }) => + NetworkService( + router: Router( + UdpConnection(), + encryptionKeyPair: KeyPairData( + pubKey: keyBunch.encryptionPublicKey, + secretKey: keyBunch.encryptionPrivateKey, + ), + signKeyPair: KeyPairData( + pubKey: keyBunch.singPublicKey, + secretKey: keyBunch.singPrivateKey, + ), + bootstrapServerAddress: bsAddressV4 != null + ? Peer( + InternetAddress(bsAddressV4, type: InternetAddressType.IPv4), + bsPort, + ) + : null, + bootstrapServerAddressIpv6: bsAddressV6 != null + ? Peer( + InternetAddress(bsAddressV6, type: InternetAddressType.IPv6), + bsPort, + ) + : null, + ), + ); + + PeerId get myPeerId => PeerId(value: router.pubKey.data); + + List get myIPs => router.connection.addresses; + + Stream get guardianStream => _guardianStreamController.stream; + + Stream get recoveryGroupStream => + _recoveryGroupStreamController.stream; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) async => + state == AppLifecycleState.resumed + ? await router.run() + : await router.stop(); + + @override + Uint64List topics() => + Uint64List.fromList([_topicOfRecoveryGroup, _topicOfGuardian]); + + @override + void onMessage(Header header, Uint8List data, Peer peer) { + if (data.isEmpty) return; + switch (header.topic) { + case _topicOfGuardian: + _guardianStreamController.add( + MessageModel.fromBytes(data, PeerId(value: header.srcKey.data))); + break; + case _topicOfRecoveryGroup: + _recoveryGroupStreamController.add( + MessageModel.fromBytes(data, PeerId(value: header.srcKey.data))); + break; + } + } + + void setBootstrapServer(String? ipV4, String? ipV6, [int bsPort = 4349]) { + final isProxyEnabled = ipV4 != null && ipV6 != null; + Settings.enableBootstrapProxy = isProxyEnabled; + Settings.enableBootstrapSearch = isProxyEnabled; + router.setBootstrapServer( + ipV4 == null ? null : Peer(InternetAddress(ipV4), bsPort), + ipV6 == null ? null : Peer(InternetAddress(ipV6), bsPort), + ); + } + + void addPeer(PeerId peerId, Uint8List address, {bool enableSearch = false}) { + final ip = InternetAddress.fromRawAddress(address); + router.addPeer( + PubKey(peerId.value), + Peer( + ip, + ip.type == InternetAddressType.IPv4 + ? router.connection.ipv4Port + : router.connection.ipv6Port, + ), + enableSearch: enableSearch, + ); + } + + bool getPeerStatus(PeerId peerId) => + router.getPeerStatus(PubKey(peerId.value)); + + Future pingPeer({ + required PeerId peerId, + bool staticCheck = true, + Duration? timeout, + }) => + router.pingPeer( + PubKey(peerId.value), + staticCheck: staticCheck, + timeout: timeout, + ); + + StreamSubscription onPeerStatusChanged( + void Function(bool) callback, + PeerId peerId, + ) => + router.onPeerStatusChanged(callback, PubKey(peerId.value)); + + Future sendToRecoveryGroup(MessageModel message, {withAck = true}) => + router + .sendTo( + _topicOfRecoveryGroup, + PubKey(message.peerId.value), + message.toBytes(), + ack: withAck ? Ack.required : Ack.no, + ) + .catchError((_) {}, test: (e) => e is TimeoutException); + + Future sendToGuardian(MessageModel message, {withAck = true}) => router + .sendTo( + _topicOfGuardian, + PubKey(message.peerId.value), + message.toBytes(), + ack: withAck ? Ack.required : Ack.no, + ) + .catchError((_) {}, test: (e) => e is TimeoutException); +} + +Future initCrypto() => P2PCrypto().init(); + +Future generateKeyBunch() async { + final encryptionAesKey = (await P2PCrypto().encryptionKeyPair()).pubKey; + final encryptionKeyPair = await P2PCrypto().encryptionKeyPair(); + final signKeyPair = await P2PCrypto().signKeyPair(); + return KeyBunch( + encryptionPrivateKey: encryptionKeyPair.secretKey, + encryptionPublicKey: encryptionKeyPair.pubKey, + singPrivateKey: signKeyPair.secretKey, + singPublicKey: signKeyPair.pubKey, + encryptionAesKey: encryptionAesKey, + ); +} diff --git a/lib/src/core/service/platform_service.dart b/lib/src/core/service/platform_service.dart new file mode 100644 index 00000000..a3580d6e --- /dev/null +++ b/lib/src/core/service/platform_service.dart @@ -0,0 +1,149 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:vibration/vibration.dart'; +import 'package:local_auth/local_auth.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '/src/core/model/core_model.dart'; + +class PlatformService { + static const _encryptionPublicKey = 'encryptionPublicKey'; + static const _encryptionPrivateKey = 'encryptionPrivateKey'; + static const _singPublicKey = 'singPublicKey'; + static const _singPrivateKey = 'singPrivateKey'; + static const _encryptionAesKey = 'encryptionAesKey'; + static const _secureStorage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); + static final _localAuth = LocalAuthentication(); + + static Future bootstrap() async => PlatformService( + hasBiometrics: await PlatformService.checkIfHasBiometrics()); + + static Future checkIfHasBiometrics() async => + (await _localAuth.getAvailableBiometrics()).isNotEmpty; + + final bool hasBiometrics; + + const PlatformService({this.hasBiometrics = false}); + + Future vibrate([int duration = 500]) async => + (await Vibration.hasVibrator() ?? false) + ? await Vibration.vibrate(duration: duration) + : null; + + Future authenticate({required String localizedReason}) async { + try { + return await _localAuth.authenticate( + localizedReason: localizedReason, + options: const AuthenticationOptions(biometricOnly: true), + ); + } catch (_) {} + return false; + } + + Future getDeviceName([Uint8List? appendix]) async { + final append = appendix == null + ? '' + : [ + ' ', + appendix.elementAt(0).toRadixString(16).padLeft(2, '0'), + appendix.elementAt(1).toRadixString(16).padLeft(2, '0'), + appendix.elementAt(2).toRadixString(16).padLeft(2, '0'), + ].join().toString(); + try { + final deviceInfoPlugin = DeviceInfoPlugin(); + if (Platform.isAndroid) { + final androidInfo = await deviceInfoPlugin.androidInfo; + return '${androidInfo.model}$append'; + } else if (Platform.isIOS) { + final iosInfo = await deviceInfoPlugin.iosInfo; + return '${iosInfo.model}$append'; + } + } catch (_) {} + return 'Undefined$append'; + } + + Future getKeyBunch(Future Function() genKeyBunch) async { + KeyBunch? keyBunch; + try { + keyBunch = await _readKeyBunch(); + } catch (_) {} + if (keyBunch == null) { + await _writeKeyBunch(await genKeyBunch()); + } else { + return keyBunch; + } + final newKeyBunch = await _readKeyBunch(); + if (newKeyBunch == null) throw Exception('Can`t persist keyBunch!'); + return newKeyBunch; + } + + Future _readKeyBunch() async { + late String encryptionAesKey; + late String encryptionPublicKey; + late String encryptionPrivateKey; + late String singPublicKey; + late String singPrivateKey; + + try { + encryptionPublicKey = + await _secureStorage.read(key: _encryptionPublicKey) ?? ''; + encryptionPrivateKey = + await _secureStorage.read(key: _encryptionPrivateKey) ?? ''; + singPublicKey = await _secureStorage.read(key: _singPublicKey) ?? ''; + singPrivateKey = await _secureStorage.read(key: _singPrivateKey) ?? ''; + encryptionAesKey = + await _secureStorage.read(key: _encryptionAesKey) ?? ''; + } catch (_) { + await _secureStorage.deleteAll(); + return null; + } + + if (encryptionPublicKey.isEmpty || + encryptionPrivateKey.isEmpty || + singPublicKey.isEmpty || + singPrivateKey.isEmpty || + encryptionAesKey.isEmpty) { + await _secureStorage.deleteAll(); + return null; + } + + try { + return KeyBunch( + encryptionPublicKey: base64Decode(encryptionPublicKey), + encryptionPrivateKey: base64Decode(encryptionPrivateKey), + singPublicKey: base64Decode(singPublicKey), + singPrivateKey: base64Decode(singPrivateKey), + encryptionAesKey: base64Decode(encryptionAesKey), + ); + } catch (_) { + await _secureStorage.deleteAll(); + return null; + } + } + + Future _writeKeyBunch(KeyBunch keyBunch) async { + await _secureStorage.write( + key: _encryptionPublicKey, + value: base64UrlEncode(keyBunch.encryptionPublicKey), + ); + await _secureStorage.write( + key: _encryptionPrivateKey, + value: base64UrlEncode(keyBunch.encryptionPrivateKey), + ); + await _secureStorage.write( + key: _singPublicKey, + value: base64UrlEncode(keyBunch.singPublicKey), + ); + await _secureStorage.write( + key: _singPrivateKey, + value: base64UrlEncode(keyBunch.singPrivateKey), + ); + await _secureStorage.write( + key: _encryptionAesKey, + value: base64UrlEncode(keyBunch.encryptionAesKey), + ); + } +} diff --git a/lib/src/core/theme_data.dart b/lib/src/core/theme_data.dart new file mode 100644 index 00000000..a7bdbc44 --- /dev/null +++ b/lib/src/core/theme_data.dart @@ -0,0 +1,292 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +const clIndigo900 = Color(0xFF1A0244); +const clIndigo800 = Color(0xFF300077); +const clIndigo700 = Color(0xFF3C089F); +const clIndigo600 = Color(0xFF5600D6); +const clIndigo500 = Color(0xFF570FE4); +const clSurface = Color(0xFF24035F); +const clRed = Color(0xFFEC5F59); +const clGreen = Color(0xFF62D6CB); +const clGreenDark = Color(0xFF004D45); +const clBlue = Color(0xFF54BAF9); +const clYellow = Color(0xFFF19C38); +const clPurpleLight = Color(0xFFE6DEF8); +const clWhite = Colors.white; +const clBlack = Colors.black; + +const paddingAll0 = EdgeInsets.all(0); +const paddingAll8 = EdgeInsets.all(8); +const paddingAll20 = EdgeInsets.all(20); +const paddingH20 = EdgeInsets.symmetric(horizontal: 20); +const paddingV6 = EdgeInsets.symmetric(vertical: 6); +const paddingV12 = EdgeInsets.symmetric(vertical: 12); +const paddingV20 = EdgeInsets.symmetric(vertical: 20); +const paddingV32 = EdgeInsets.symmetric(vertical: 32); +const paddingTop12 = EdgeInsets.only(top: 12); +const paddingTop20 = EdgeInsets.only(top: 20); +const paddingTop32 = EdgeInsets.only(top: 32); +const paddingBottom12 = EdgeInsets.only(bottom: 12); +const paddingBottom20 = EdgeInsets.only(bottom: 20); +const paddingBottom32 = EdgeInsets.only(bottom: 32); +const paddingV32H20 = EdgeInsets.symmetric(horizontal: 20, vertical: 32); + +const textStyleBold = TextStyle(fontWeight: FontWeight.w600); + +const radius8 = Radius.circular(8); + +const borderRadiusTop = BorderRadius.vertical(top: radius8); + +final borderRadius = BorderRadius.circular(8); + +final boxDecoration = BoxDecoration( + borderRadius: borderRadius, + color: clSurface, +); + +final boxDecorationStart = BoxDecoration( + borderRadius: borderRadius, + color: clIndigo500, +); + +final textStyleSourceSansPro412 = GoogleFonts.sourceSansPro( + fontSize: 12, + fontWeight: FontWeight.w400, +); + +final textStyleSourceSansPro412Purple = GoogleFonts.sourceSansPro( + color: clPurpleLight, + fontSize: 12, + fontWeight: FontWeight.w400, +); + +final textStyleSourceSansPro414 = GoogleFonts.sourceSansPro( + fontSize: 14, + fontWeight: FontWeight.w400, +); + +final textStyleSourceSansPro414Purple = GoogleFonts.sourceSansPro( + color: clPurpleLight, + fontSize: 14, + fontWeight: FontWeight.w400, +); + +final textStyleSourceSansPro416 = GoogleFonts.sourceSansPro( + fontSize: 16, + fontWeight: FontWeight.w400, +); + +final textStyleSourceSansPro416Purple = GoogleFonts.sourceSansPro( + color: clPurpleLight, + fontSize: 16, + fontWeight: FontWeight.w400, +); + +final textStyleSourceSansPro612 = GoogleFonts.sourceSansPro( + fontSize: 12, + fontWeight: FontWeight.w600, +); + +final textStyleSourceSansPro612Purple = GoogleFonts.sourceSansPro( + color: clPurpleLight, + fontSize: 12, + fontWeight: FontWeight.w600, +); + +final textStyleSourceSansPro614 = GoogleFonts.sourceSansPro( + fontSize: 14, + fontWeight: FontWeight.w600, +); + +final textStyleSourceSansPro614Purple = GoogleFonts.sourceSansPro( + color: clPurpleLight, + fontSize: 14, + fontWeight: FontWeight.w600, +); + +final textStyleSourceSansPro616 = GoogleFonts.sourceSansPro( + fontSize: 16, + fontWeight: FontWeight.w600, +); + +final textStylePoppins616 = GoogleFonts.poppins( + fontSize: 16, + fontWeight: FontWeight.w600, +); + +final textStylePoppins620 = GoogleFonts.poppins( + fontSize: 20, + fontWeight: FontWeight.w600, +); + +final themeLight = ThemeData.light(); + +final themeDark = ThemeData.dark().copyWith( + // Color Scheme + colorScheme: const ColorScheme( + brightness: Brightness.dark, + primary: clWhite, + onPrimary: clIndigo600, + secondary: clIndigo700, + onSecondary: clWhite, + error: clRed, + onError: clYellow, + background: clIndigo900, + onBackground: clBlue, + surface: clSurface, + onSurface: clWhite, + ), + scaffoldBackgroundColor: clIndigo900, + backgroundColor: clIndigo900, + canvasColor: clIndigo900, + // AppBar + appBarTheme: AppBarTheme( + backgroundColor: clIndigo900, + centerTitle: true, + titleTextStyle: textStylePoppins616, + toolbarHeight: 68, + ), + // Bottom Navigation Bar + bottomNavigationBarTheme: BottomNavigationBarThemeData( + type: BottomNavigationBarType.fixed, + backgroundColor: clIndigo900, + selectedItemColor: clWhite, + selectedLabelStyle: textStyleSourceSansPro612.copyWith(height: 2.5), + unselectedLabelStyle: textStyleSourceSansPro412.copyWith(height: 2.5), + ), + // Bottom Sheet + bottomSheetTheme: const BottomSheetThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: radius8, + topRight: radius8, + ))), + // Card + cardTheme: CardTheme( + color: clSurface, + elevation: 0, + margin: paddingAll0, + shape: _shapeBorder, + ), + // Elevated Button + elevatedButtonTheme: ElevatedButtonThemeData( + style: ButtonStyle( + fixedSize: _fixedSizeHeight48, + foregroundColor: _buttonForegroundColor, + shape: _buttonShape, + textStyle: MaterialStateProperty.all(textStylePoppins616), + backgroundColor: MaterialStateProperty.resolveWith((states) => + states.contains(MaterialState.disabled) + ? const Color(0xFF320784) + : clIndigo500), + )), + // Expansion Panel + expansionTileTheme: const ExpansionTileThemeData( + backgroundColor: clSurface, + childrenPadding: paddingAll20, + ), + // Icon + iconTheme: const IconThemeData(color: clWhite), + // Input + inputDecorationTheme: InputDecorationTheme( + border: MaterialStateOutlineInputBorder.resolveWith( + (states) { + var borderWidth = 1.0; + var borderColor = const Color(0x55E9F8FE); + if (states.contains(MaterialState.focused)) { + borderWidth = 2; + borderColor = clBlue; + } + if (states.contains(MaterialState.error)) { + borderColor = clRed; + } + return OutlineInputBorder( + borderRadius: borderRadius, + borderSide: BorderSide(width: borderWidth, color: borderColor), + ); + }, + ), + counterStyle: textStyleSourceSansPro412, + helperStyle: textStyleSourceSansPro412, + labelStyle: textStyleSourceSansPro412, + ), + // ListTile + listTileTheme: ListTileThemeData( + iconColor: clWhite, + tileColor: clSurface, + textColor: clWhite, + shape: _shapeBorder, + ), + // Outlined Button + outlinedButtonTheme: OutlinedButtonThemeData( + style: ButtonStyle( + fixedSize: _fixedSizeHeight48, + foregroundColor: _buttonForegroundColor, + side: MaterialStateProperty.resolveWith( + (states) => BorderSide( + color: states.contains(MaterialState.disabled) + ? const Color(0xFF2E4283) + : clIndigo500), + ), + shape: _buttonShape, + textStyle: MaterialStateProperty.all(textStylePoppins616), + )), + // Radio + radioTheme: RadioThemeData( + fillColor: MaterialStateProperty.all(clWhite), + ), + // SnackBar + snackBarTheme: const SnackBarThemeData( + backgroundColor: clGreen, + behavior: SnackBarBehavior.floating, + contentTextStyle: TextStyle(color: clGreenDark), + ), + // Switch + switchTheme: SwitchThemeData( + thumbColor: MaterialStateProperty.all(clWhite), + trackColor: MaterialStateProperty.resolveWith((states) => + states.contains(MaterialState.selected) ? clBlue : clIndigo700), + ), + // TabBar + tabBarTheme: TabBarTheme( + indicator: const BoxDecoration( + borderRadius: borderRadiusTop, + color: clIndigo600, + ), + labelPadding: paddingAll0, + labelColor: clWhite, + labelStyle: textStyleSourceSansPro614, + unselectedLabelColor: clWhite, + unselectedLabelStyle: textStyleSourceSansPro614, + ), + // Text + textTheme: TextTheme( + caption: const TextStyle( + color: clPurpleLight, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + headline6: textStylePoppins620, + subtitle1: textStyleSourceSansPro614, + bodyText2: textStyleSourceSansPro414, + ), + // Text Button + textButtonTheme: TextButtonThemeData( + style: ButtonStyle( + foregroundColor: MaterialStateProperty.all(clWhite), + )), +); + +final _fixedSizeHeight48 = + MaterialStateProperty.all(const Size(double.infinity, 48)); + +final _shapeBorder = RoundedRectangleBorder(borderRadius: borderRadius); + +final _buttonShape = MaterialStateProperty.all( + RoundedRectangleBorder(borderRadius: borderRadius)); + +final _buttonForegroundColor = MaterialStateProperty.resolveWith( + (states) => states.contains(MaterialState.disabled) + ? const Color(0xFF76678F) + : clWhite); diff --git a/lib/src/core/utils/random_utils.dart b/lib/src/core/utils/random_utils.dart new file mode 100644 index 00000000..ad9ddf3f --- /dev/null +++ b/lib/src/core/utils/random_utils.dart @@ -0,0 +1,12 @@ +import 'dart:math' show Random; +import 'dart:typed_data' show Uint8List; + +// const _chars = '0123456789ABCDEF'; +final _random = Random.secure(); + +// String getRandomString([int length = 64]) => +// String.fromCharCodes(Iterable.generate( +// length, (_) => _chars.codeUnitAt(_random.nextInt(_chars.length)))); + +Uint8List getRandomBytes([int length = 32]) => Uint8List.fromList( + Iterable.generate(length, (x) => _random.nextInt(255)).toList()); diff --git a/lib/src/core/widgets/auth.dart b/lib/src/core/widgets/auth.dart new file mode 100644 index 00000000..c8bc912e --- /dev/null +++ b/lib/src/core/widgets/auth.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screen_lock/flutter_screen_lock.dart'; +import 'package:guardian_keyper/src/core/theme_data.dart'; + +export 'package:flutter_screen_lock/flutter_screen_lock.dart'; + +import '/src/core/di_container.dart'; +import '/src/core/model/core_model.dart'; +import '/src/core/widgets/icon_of.dart'; + +const secretsConfig = SecretsConfig( + secretConfig: SecretConfig( + borderSize: 0, + borderColor: Colors.transparent, + disabledColor: Colors.white38, + enabledColor: Colors.white, + ), +); +final keyPadConfig = KeyPadConfig( + buttonConfig: StyledInputConfig( + buttonStyle: ButtonStyle( + backgroundColor: MaterialStateProperty.all(Colors.transparent), + ), + ), + clearOnLongPressed: true, +); +const screenLockConfig = ScreenLockConfig(backgroundColor: clIndigo900); + +class BiometricLogonButton extends StatefulWidget { + final void Function() callback; + + const BiometricLogonButton({super.key, required this.callback}); + + @override + State createState() => _BiometricLogonButtonState(); +} + +class _BiometricLogonButtonState extends State { + @override + void initState() { + super.initState(); + if (context.read().boxSettings.isBiometricsEnabled) { + _authenticate(); + } + } + + @override + Widget build(BuildContext context) => + ValueListenableBuilder>( + valueListenable: context.read().boxSettings.listenable(), + builder: (_, boxSettings, __) => boxSettings.isBiometricsEnabled + ? IconButton( + iconSize: 48, + icon: const IconOf.biometricLogon( + bgColor: Colors.transparent, + size: 48, + ), + onPressed: _authenticate, + ) + : const Offstage(), + ); + + void _authenticate() async { + final didAuthenticate = + await context.read().platformService.authenticate( + localizedReason: 'Please authenticate to log into the app', + ); + if (didAuthenticate && mounted) widget.callback(); + } +} diff --git a/lib/src/core/widgets/common.dart b/lib/src/core/widgets/common.dart new file mode 100644 index 00000000..cfd602e2 --- /dev/null +++ b/lib/src/core/widgets/common.dart @@ -0,0 +1,393 @@ +import 'package:flutter/material.dart'; + +import '../theme_data.dart'; + +export 'package:flutter/material.dart'; + +class ScaffoldWidget extends StatelessWidget { + final Widget child; + final BottomNavigationBar? bottomNavigationBar; + final Future Function()? onWillPop; + + const ScaffoldWidget({ + super.key, + required this.child, + this.bottomNavigationBar, + this.onWillPop, + }); + + @override + Widget build(BuildContext context) => WillPopScope( + onWillPop: onWillPop, + child: Scaffold( + primary: true, + resizeToAvoidBottomInset: true, + bottomNavigationBar: bottomNavigationBar, + body: SafeArea(child: child), + ), + ); +} + +class HeaderBar extends StatelessWidget { + static const double sideSize = 68; + + final String? caption; + final Widget? backButton; + final Widget? closeButton; + + const HeaderBar({ + super.key, + this.caption, + this.backButton, + this.closeButton, + }); + + @override + Widget build(BuildContext context) => Container( + height: sideSize, + color: Theme.of(context).colorScheme.background, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + height: sideSize, + width: sideSize, + alignment: Alignment.center, + child: backButton, + ), + Expanded( + child: Container( + height: sideSize, + alignment: Alignment.center, + child: caption == null + ? null + : Text( + caption!, + style: textStylePoppins616, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + ), + )), + Container( + height: sideSize, + width: sideSize, + alignment: Alignment.center, + child: closeButton, + ), + ], + ), + ); +} + +class HeaderBarCloseButton extends StatelessWidget { + final void Function()? onPressed; + + const HeaderBarCloseButton({super.key, this.onPressed}); + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: onPressed ?? Navigator.of(context).pop, + behavior: HitTestBehavior.opaque, + child: Center( + child: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondary, + child: const Icon(Icons.close, color: clWhite), + ), + ), + ); +} + +class HeaderBarMoreButton extends StatelessWidget { + final void Function()? onPressed; + + const HeaderBarMoreButton({super.key, this.onPressed}); + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: onPressed, + behavior: HitTestBehavior.opaque, + child: Center( + child: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondary, + child: const Icon(Icons.keyboard_control, color: clWhite), + ), + ), + ); +} + +class HeaderBarBackButton extends StatelessWidget { + final void Function()? onPressed; + + const HeaderBarBackButton({super.key, this.onPressed}); + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: onPressed ?? Navigator.of(context).pop, + behavior: HitTestBehavior.opaque, + child: Center( + child: CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.secondary, + child: const Icon(Icons.arrow_back, color: clWhite), + ), + ), + ); +} + +class PageTitle extends StatelessWidget { + final String title; + final String? subtitle; + final List? subtitleSpans; + + const PageTitle({ + super.key, + required this.title, + this.subtitle, + this.subtitleSpans, + }); + + @override + Widget build(BuildContext context) => Padding( + padding: paddingH20, + child: Column( + children: [ + Padding( + padding: paddingTop32, + child: Text( + title, + style: textStylePoppins620, + textAlign: TextAlign.center, + ), + ), + if (subtitle != null || subtitleSpans != null) + Padding( + padding: paddingTop20, + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + text: subtitle, + style: + textStyleSourceSansPro414Purple.copyWith(height: 1.5), + children: subtitleSpans, + ), + ), + ), + const Padding(padding: paddingTop32), + ], + ), + ); +} + +class PrimaryButton extends StatelessWidget { + final String text; + final void Function()? onPressed; + + const PrimaryButton({ + super.key, + required this.text, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) => Material( + child: Ink( + decoration: BoxDecoration( + borderRadius: borderRadius, + gradient: onPressed == null + ? const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF548BB4), Color(0xFF2E68AC)], + ) + : const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF7BE7FF), Color(0xFF3AABF0)], + ), + ), + height: 48, + child: InkWell( + borderRadius: borderRadius, + onTap: onPressed, + splashColor: clBlue, + child: Center( + child: Text( + text, + overflow: TextOverflow.clip, + maxLines: 1, + style: textStylePoppins616.copyWith( + color: onPressed == null ? const Color(0xFF8FB1D0) : clWhite, + ), + ), + ), + ), + ), + ); +} + +class BottomSheetWidget extends StatelessWidget { + final Widget? icon; + final String? titleString; + final String? textString; + final List? textSpan; + final Widget? body; + final Widget? footer; + + const BottomSheetWidget({ + super.key, + this.icon, + this.titleString, + this.textString, + this.textSpan, + this.body, + this.footer, + }); + + @override + Widget build(BuildContext context) => Padding( + padding: paddingAll20, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) Padding(padding: paddingBottom32, child: icon), + if (titleString != null) + Padding( + padding: paddingBottom12, + child: Text( + titleString!, + style: textStylePoppins620, + textAlign: TextAlign.center, + ), + ), + if (textString != null || textSpan != null) + Padding( + padding: paddingBottom12, + child: RichText( + textAlign: TextAlign.center, + softWrap: true, + overflow: TextOverflow.clip, + text: TextSpan( + style: textStyleSourceSansPro414Purple, + text: textString, + children: textSpan, + ), + ), + ), + if (body != null) body!, + if (footer != null) Padding(padding: paddingV20, child: footer), + ], + ), + ); +} + +class InfoPanel extends StatelessWidget { + final AnimationController? animationController; + final String? title; + final String? text; + final List? textSpan; + final IconData? icon; + final Color color; + + const InfoPanel({ + super.key, + this.title, + this.text, + this.textSpan, + this.icon, + required this.color, + this.animationController, + }); + + const InfoPanel.info({ + super.key, + this.title, + this.text, + this.textSpan, + this.icon = Icons.info_outline, + this.color = clBlue, + this.animationController, + }); + + const InfoPanel.warning({ + super.key, + this.title, + this.text, + this.textSpan, + this.icon = Icons.error_outline, + this.color = clYellow, + this.animationController, + }); + + const InfoPanel.error({ + super.key, + this.title, + this.text, + this.textSpan, + this.icon = Icons.error, + this.color = clRed, + this.animationController, + }); + + @override + Widget build(BuildContext context) => animationController == null + ? buildBody() + : DecoratedBoxTransition( + decoration: DecorationTween( + begin: boxDecorationStart, + end: boxDecoration, + ).animate(animationController!), + child: buildBody(), + ); + + Widget buildBody() => Container( + decoration: animationController == null ? boxDecoration : null, + padding: paddingAll20, + child: Column( + children: [ + if (icon != null) Icon(icon, color: color, size: 20), + if (title != null) + Padding( + padding: paddingTop12, + child: Text( + title!, + style: textStyleSourceSansPro616, + textAlign: TextAlign.center, + ), + ), + if (text != null || textSpan != null) + Padding( + padding: paddingTop12, + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + text: text, + style: textStyleSourceSansPro414Purple, + children: textSpan, + ), + ), + ), + ], + ), + ); +} + +SnackBar buildSnackBar({ + String? text, + List? textSpans, + bool isFloating = false, + bool isError = false, + Duration duration = const Duration(seconds: 4), +}) => + SnackBar( + duration: duration, + behavior: isFloating ? SnackBarBehavior.floating : null, + margin: paddingAll20, + backgroundColor: isError ? clRed : clGreen, + content: text != null + ? Text(text, style: TextStyle(color: isError ? clWhite : clGreenDark)) + : RichText( + text: TextSpan( + children: textSpans, + style: TextStyle(color: isError ? clWhite : clGreenDark), + )), + ); diff --git a/lib/src/core/widgets/icon_of.dart b/lib/src/core/widgets/icon_of.dart new file mode 100644 index 00000000..c103e01d --- /dev/null +++ b/lib/src/core/widgets/icon_of.dart @@ -0,0 +1,292 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../theme_data.dart'; + +enum BageType { ok, warning, error } + +class IconOf extends StatelessWidget { + final String icon; + final double? size; + final bool? isBig; + final Color? color; + final Color? bgColor; + final BageType? bage; + + const IconOf.app({ + super.key, + this.size, + this.isBig, + this.color, + this.bgColor, + this.bage, + }) : icon = 'assets/icons/logo.svg'; + + const IconOf.navBarHome({ + super.key, + this.size = 30, + this.isBig, + this.color, + this.bgColor = Colors.transparent, + this.bage, + }) : icon = 'assets/icons/navbar_home_v1.svg'; + + const IconOf.navBarHomeSelected({ + super.key, + this.size = 30, + this.isBig, + this.color, + this.bgColor = Colors.transparent, + this.bage, + }) : icon = 'assets/icons/navbar_home_selected_v1.svg'; + + const IconOf.navBarKey({ + super.key, + this.size = 30, + this.isBig, + this.color, + this.bgColor = Colors.transparent, + this.bage, + }) : icon = 'assets/icons/navbar_key_v1.svg'; + + const IconOf.navBarKeySelected({ + super.key, + this.size = 30, + this.isBig, + this.color, + this.bgColor = Colors.transparent, + this.bage, + }) : icon = 'assets/icons/navbar_key_selected_v1.svg'; + + const IconOf.navBarShield({ + super.key, + this.size = 30, + this.isBig, + this.color, + this.bgColor = Colors.transparent, + this.bage, + }) : icon = 'assets/icons/navbar_shield_v1.svg'; + + const IconOf.navBarShieldSelected({ + super.key, + this.size = 30, + this.isBig, + this.color, + this.bgColor = Colors.transparent, + this.bage, + }) : icon = 'assets/icons/navbar_shield_selected_v1.svg'; + + const IconOf.navBarBell({ + super.key, + this.size = 30, + this.isBig, + this.color, + this.bgColor = Colors.transparent, + this.bage, + }) : icon = 'assets/icons/navbar_bell_v1.svg'; + + const IconOf.navBarBellSelected({ + super.key, + this.size = 30, + this.isBig, + this.color, + this.bgColor = Colors.transparent, + this.bage, + }) : icon = 'assets/icons/navbar_bell_selected_v1.svg'; + + const IconOf.yourDevices({ + super.key, + this.size, + this.isBig, + this.color, + this.bgColor, + this.bage, + }) : icon = 'assets/icons/your_devices_v1.svg'; + + const IconOf.fiduciaries({ + super.key, + this.size, + this.isBig, + this.color, + this.bgColor, + this.bage, + }) : icon = 'assets/icons/fiduciaries_v1.svg'; + + const IconOf.owner({ + super.key, + this.size, + this.isBig, + this.color, + this.bgColor, + this.bage, + }) : icon = 'assets/icons/owner_v1.svg'; + + const IconOf.shardOwner({ + super.key, + this.size, + this.isBig, + this.color, + this.bgColor, + this.bage, + }) : icon = 'assets/icons/shard_owner_v1.svg'; + + const IconOf.removeGroup({ + super.key, + this.size, + this.isBig, + this.color, + this.bgColor, + this.bage, + }) : icon = 'assets/icons/remove_group_v1.svg'; + + const IconOf.secrets({ + super.key, + this.size, + this.isBig, + this.color, + this.bgColor, + this.bage, + }) : icon = 'assets/icons/no_secrets_v1.svg'; + + const IconOf.secret({ + super.key, + this.size, + this.isBig, + this.color, + this.bgColor, + this.bage, + }) : icon = 'assets/icons/secret_v1.svg'; + + const IconOf.secretRestoration({ + super.key, + this.size, + this.isBig, + this.color, + this.bgColor, + this.bage, + }) : icon = 'assets/icons/secret_restoration_v1.svg'; + + const IconOf.shield({ + super.key, + this.size, + this.isBig, + this.color, + this.bgColor, + this.bage, + }) : icon = 'assets/icons/shield_v1.svg'; + + const IconOf.scanQR({ + super.key, + this.size, + this.isBig, + this.color, + this.bgColor, + this.bage, + }) : icon = 'assets/icons/scan_qr_v1.svg'; + + const IconOf.splitAndShare({ + super.key, + this.size, + this.isBig, + this.color, + this.bgColor, + this.bage, + }) : icon = 'assets/icons/split_and_share_v1.svg'; + + const IconOf.share({ + super.key, + this.size, + this.isBig, + this.color, + this.bgColor, + this.bage, + }) : icon = 'assets/icons/share_v1.svg'; + + const IconOf.passcode({ + super.key, + this.size, + this.isBig, + this.color, + this.bgColor, + this.bage, + }) : icon = 'assets/icons/passcode_v1.svg'; + + const IconOf.biometricLogon({ + super.key, + this.size, + this.isBig, + this.color, + this.bgColor, + this.bage, + }) : icon = 'assets/icons/biometric_logon_v1.svg'; + + @override + Widget build(BuildContext context) { + final actualSize = (size ?? 40) * (isBig == true ? 2 : 1); + final iconWidget = Container( + decoration: BoxDecoration( + color: bgColor ?? Theme.of(context).colorScheme.secondary, + shape: BoxShape.circle, + ), + height: actualSize, + width: actualSize, + child: SvgPicture.asset(icon, color: color), + ); + if (bage == null) return iconWidget; + + late Widget bageWidget; + late Color bageColor; + final bageSize = actualSize / 4; + + switch (bage) { + case BageType.ok: + bageColor = clGreen; + bageWidget = Icon( + Icons.done, + color: clWhite, + size: bageSize, + ); + break; + case BageType.warning: + bageColor = clYellow; + bageWidget = Icon( + Icons.priority_high_outlined, + color: clWhite, + size: bageSize, + ); + break; + case BageType.error: + bageColor = clRed; + bageWidget = Icon( + Icons.close, + color: clWhite, + size: bageSize, + ); + break; + default: + } + + return SizedBox( + height: actualSize, + width: actualSize, + child: Stack( + clipBehavior: Clip.none, + children: [ + iconWidget, + Positioned( + bottom: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + color: bageColor, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(4), + child: bageWidget, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/core/widgets/misc.dart b/lib/src/core/widgets/misc.dart new file mode 100644 index 00000000..834bcfaa --- /dev/null +++ b/lib/src/core/widgets/misc.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; + +import '../theme_data.dart'; + +export 'package:flutter/material.dart'; + +class CircleNumber extends StatelessWidget { + final double size; + final int number; + + const CircleNumber({super.key, this.size = 24, required this.number}); + + @override + Widget build(BuildContext context) => Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.blueAccent), + shape: BoxShape.circle, + ), + height: size, + width: size, + child: Center(child: Text(number.toString())), + ); +} + +class NumberedListWidget extends StatelessWidget { + final List list; + + const NumberedListWidget({super.key, required this.list}); + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < list.length; i++) + Container( + alignment: Alignment.centerLeft, + margin: const EdgeInsets.symmetric(vertical: 12), + child: Row(children: [ + Padding( + padding: const EdgeInsets.only(right: 12), + child: CircleNumber(number: i + 1), + ), + Expanded( + child: Text( + list[i], + softWrap: true, + style: textStyleSourceSansPro414, + ), + ), + ]), + ) + ], + ); +} + +class DotColored extends StatelessWidget { + final Widget? child; + final Color color; + final double size; + + const DotColored({ + super.key, + this.child, + this.color = clWhite, + this.size = 8, + }); + + @override + Widget build(BuildContext context) => Container( + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + height: size, + width: size, + child: child, + ); +} + +class DotBar extends StatelessWidget { + final int count; + final int active; + final Color activeColor; + final Color passiveColor; + + const DotBar({ + super.key, + required this.count, + this.active = 0, + this.activeColor = clWhite, + this.passiveColor = clIndigo700, + }); + + @override + Widget build(BuildContext context) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var i = 0; i < count; i++) + Padding( + padding: const EdgeInsets.only(right: 8), + child: + DotColored(color: i == active ? activeColor : passiveColor), + ), + ], + ); +} + +class SelectableCard extends StatelessWidget { + final Widget child; + final bool isSelected; + + const SelectableCard({ + super.key, + required this.child, + this.isSelected = false, + }); + + @override + Widget build(BuildContext context) => Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: borderRadius, + color: isSelected ? clIndigo500 : clSurface, + ), + child: Stack( + children: [ + Positioned( + top: -20, + right: -30, + child: Container( + height: 180, + width: 180, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.white10, + ), + ), + ), + Positioned( + top: 30, + right: 20, + child: Container( + height: 150, + width: 150, + decoration: const BoxDecoration( + backgroundBlendMode: BlendMode.colorDodge, + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.white10], + ), + ), + ), + ), + Positioned( + top: -30, + right: -40, + child: Container( + height: 148, + width: 148, + decoration: const BoxDecoration( + backgroundBlendMode: BlendMode.colorDodge, + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.white12, Colors.transparent], + ), + ), + ), + ), + Padding(padding: paddingAll20, child: child), + ], + ), + ); +} diff --git a/lib/src/guardian/guardian_controller.dart b/lib/src/guardian/guardian_controller.dart new file mode 100644 index 00000000..6274219d --- /dev/null +++ b/lib/src/guardian/guardian_controller.dart @@ -0,0 +1,173 @@ +import '/src/core/di_container.dart' show SettingsModelExt; +import '/src/core/controller/base_controller.dart'; +import '/src/core/model/core_model.dart'; + +export 'package:provider/provider.dart'; + +class GuardianController extends BaseController { + GuardianController({required super.diContainer}) { + diContainer.networkService.guardianStream.listen(onMessage); + _cleanMessageBox(); + } + + Iterable get messages => diContainer.boxMessages.values; + + Iterable get messagesProcessed => + diContainer.boxMessages.values.where((e) => e.isProcessed); + + Iterable get secretShards => + diContainer.boxSecretShards.values; + + MessageModel? getMessageByKey(String? key) => + diContainer.boxMessages.get(key); + + SecretShardModel? getSecretShardByKey(String? key) => + diContainer.boxSecretShards.get(key); + + QRCode getQrCode([SecretShardModel? secretShard]) { + final message = MessageModel( + type: secretShard == null + ? OperationType.authPeer + : OperationType.takeOwnership, + status: MessageStatus.started, + nonce: Nonce.aNew(), + secretShard: secretShard, + ); + _putMessage(message); + return QRCode( + type: message.type, + nonce: message.nonce, + peerId: diContainer.networkService.myPeerId, + peerName: diContainer.boxSettings.readWhole().deviceName, + addresses: diContainer.networkService.myIPs, + ); + } + + void onMessage(MessageModel message) { + if (message.secretShard.isEmpty) return; + final request = getMessageByKey(message.aKey); + if (message.type != OperationType.getShard) { + if (request == null || + request.type != message.type || + request.hasResponse) { + diContainer.networkService.sendToRecoveryGroup( + message.copyWith(status: MessageStatus.failed)); + return; + } + if (message.isProcessed) return; + } + + switch (message.type) { + case OperationType.authPeer: + _putMessage(message.process(message.peerId)); + break; + + case OperationType.setShard: + if (message.peerId != request!.secretShard.ownerId) return; + _putMessage(message.process(message.peerId)); + break; + + case OperationType.getShard: + final secretShard = + diContainer.boxSecretShards.get(message.secretShard.asKey); + if (secretShard?.ownerId != message.peerId) return; + _putMessage(MessageModel( + peerId: secretShard!.ownerId, + type: OperationType.getShard, + status: MessageStatus.processed, + nonce: message.nonce, + secretShard: secretShard, + )); + break; + + case OperationType.takeOwnership: + _putMessage(request!.process( + message.peerId, + message.secretShard.ownerName, + )); + break; + } + } + + Future sendAuthPeerResponse(MessageModel request) async { + await diContainer.networkService.sendToRecoveryGroup(request); + await _archiveMessage(request); + await _putMessage(MessageModel( + type: OperationType.setShard, + status: MessageStatus.started, + secretShard: request.secretShard.copyWith(ownerId: request.peerId), + )); + } + + Future sendSetShardResponse(MessageModel request) async { + if (request.isAccepted) { + await diContainer.boxSecretShards + .put(request.secretShard.asKey, request.secretShard); + } + request = request.clearSecret(); + await diContainer.networkService.sendToRecoveryGroup(request); + await _archiveMessage(request); + } + + Future sendGetShardResponse(MessageModel request) async { + request = request.copyWith(); + if (request.isRejected) request = request.clearSecret(); + await diContainer.networkService.sendToRecoveryGroup(request); + await _archiveMessage(request); + } + + Future sendTakeOwnershipResponse(MessageModel request) async { + await diContainer.networkService.sendToRecoveryGroup(request.clearSecret()); + if (request.isAccepted) { + await diContainer.boxSecretShards.put( + request.secretShard.asKey, + request.secretShard, + ); + } + await _archiveMessage(request); + } + + Future archivateRequest(MessageModel request) async { + if (!request.isProcessed) return; + await _archiveMessage(request.copyWith(status: MessageStatus.rejected)); + } + + Future removeShard(SecretShardModel secretShard) async { + await diContainer.boxSecretShards.delete(secretShard.asKey); + notifyListeners(); + } + + Future _putMessage(MessageModel message) async { + if (getMessageByKey(message.aKey) == message) return; + await diContainer.boxMessages.put(message.aKey, message); + if (message.isProcessed) { + diContainer.eventBus.fire(NewMessageProcessedEvent(message: message)); + } + notifyListeners(); + } + + Future _archiveMessage(MessageModel message) async { + await diContainer.boxMessages.delete(message.aKey); + if (message.secretShard.value.isNotEmpty) message = message.clearSecret(); + await diContainer.boxMessages.put( + message.timestamp.millisecondsSinceEpoch.toString(), + message, + ); + notifyListeners(); + } + + Future _cleanMessageBox() async { + if (diContainer.boxMessages.isEmpty) return; + final expired = diContainer.boxMessages.values + .where((e) => + e.isStarted && + (e.type == OperationType.authPeer || + e.type == OperationType.takeOwnership) && + e.timestamp + .isBefore(DateTime.now().subtract(const Duration(days: 1)))) + .toList(growable: false); + if (expired.isEmpty) return; + await diContainer.boxMessages.deleteAll(expired.map((e) => e.aKey)); + await diContainer.boxMessages.compact(); + } +} diff --git a/lib/src/guardian/pages/managed_secrets_page.dart b/lib/src/guardian/pages/managed_secrets_page.dart new file mode 100644 index 00000000..dc3f627f --- /dev/null +++ b/lib/src/guardian/pages/managed_secrets_page.dart @@ -0,0 +1,71 @@ +import 'package:guardian_keyper/src/core/model/core_model.dart'; + +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/icon_of.dart'; + +import '../guardian_controller.dart'; +import '../widgets/secret_list_tile_widget.dart'; + +class ManagedSecretsPage extends StatefulWidget { + const ManagedSecretsPage({super.key}); + + @override + State createState() => _ManagedSecretsPageState(); +} + +class _ManagedSecretsPageState extends State { + GroupId? expanedShardId; + + @override + Widget build(BuildContext context) { + final controller = Provider.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Header + const HeaderBar(caption: 'Shards'), + // Body + if (controller.secretShards.isEmpty) ...const [ + Padding( + padding: paddingTop32, + child: IconOf.shield(isBig: true), + ), + Padding( + padding: paddingAll20, + child: PageTitle( + title: 'You don’t have any Shards yet', + subtitle: _textSubtitle, + ), + ), + ] else + Expanded( + child: ListView( + key: Key(expanedShardId?.asHex ?? 'emptyOne'), + padding: paddingH20, + children: [ + for (final shard in controller.secretShards) + Padding( + padding: paddingV6, + child: SecretTileWidget( + secretShard: shard, + isExpanded: shard.groupId == expanedShardId, + setExpanded: (groupId) => + setState(() => expanedShardId = groupId), + ), + ) + ], + ), + ), + ], + ); + } +} + +const _textSubtitle = 'Guardian Keyper app splits seed phrases into a number ' + 'of encrypted parts called “Shards”. Shards are stored on devices ' + 'of ”Guardians”, trusted persons. They can be used to securely restore ' + 'lost or forgotten seed phrases.\n\nWhen someone asks you to become ' + 'their Guardian, you can accept an invitation and as a result get their ' + 'Shard. All Shards will be displayed on this page.'; diff --git a/lib/src/guardian/pages/message_page.dart b/lib/src/guardian/pages/message_page.dart new file mode 100644 index 00000000..86d1da99 --- /dev/null +++ b/lib/src/guardian/pages/message_page.dart @@ -0,0 +1,145 @@ +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; + +import '../guardian_controller.dart'; +import '../widgets/message_list_tile_widget.dart'; + +class MessagesPage extends StatelessWidget { + static const _tabs = [Tab(text: 'Active'), Tab(text: 'Resolved')]; + + const MessagesPage({super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: paddingH20, + child: DefaultTabController( + length: _tabs.length, + child: Scaffold( + // Header + appBar: AppBar( + title: const Text('Messages'), + bottom: const TabBar( + tabs: _tabs, + splashBorderRadius: borderRadiusTop, + ), + ), + // Body + body: Column( + children: [ + const Divider(color: clIndigo500, height: 2, thickness: 2), + Container( + height: 20, + color: Theme.of(context).colorScheme.background, + ), + const Expanded( + child: TabBarView(children: [ + _ActiveTabWidget(), + _ResolvedTabWidget(), + ])), + Container( + height: 20, + color: Theme.of(context).colorScheme.background, + ), + ], + ), + ), + ), + ); +} + +class _ActiveTabWidget extends StatelessWidget { + const _ActiveTabWidget(); + + @override + Widget build(BuildContext context) { + final controller = Provider.of(context); + final active = + controller.messages.where((e) => e.isProcessed).toList(growable: false); + active.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + return active.isEmpty + ? Center( + child: Text( + 'You don’t have any active messages', + textAlign: TextAlign.center, + style: textStyleSourceSansPro414, + softWrap: true, + ), + ) + : ListView( + children: active + .map((msg) => Padding( + padding: paddingV6, + child: Dismissible( + key: Key(msg.aKey), + background: Container( + alignment: Alignment.centerLeft, + color: Theme.of(context).colorScheme.background, + height: double.infinity, + padding: paddingH20, + child: Text( + 'Move to Resolved', + style: textStyleSourceSansPro416Purple, + ), + ), + direction: DismissDirection.startToEnd, + confirmDismiss: (_) => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => BottomSheetWidget( + titleString: 'Are you sure?', + textString: 'This Request will be moved to Resolved' + ' and you will not able to Approve it!', + footer: Row(children: [ + Expanded( + child: ElevatedButton( + onPressed: () => + Navigator.of(context).pop(false), + child: const Text('No'), + )), + const SizedBox(width: 10), + Expanded( + child: PrimaryButton( + onPressed: () => + Navigator.of(context).pop(true), + text: 'Yes', + )), + ]), + ), + ), + onDismissed: (_) => controller.archivateRequest(msg), + child: MessageListTile(message: msg), + ), + )) + .toList(), + ); + } +} + +class _ResolvedTabWidget extends StatelessWidget { + const _ResolvedTabWidget(); + + @override + Widget build(BuildContext context) { + final controller = Provider.of(context); + final resolved = + controller.messages.where((e) => e.isResolved).toList(growable: false); + resolved.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + return resolved.isEmpty + ? Center( + child: Text( + 'You don’t have any resolved messages', + textAlign: TextAlign.center, + style: textStyleSourceSansPro414, + softWrap: true, + ), + ) + : ListView( + children: resolved + .map((e) => Padding( + padding: paddingV6, + child: MessageListTile(message: e), + )) + .toList(), + ); + } +} diff --git a/lib/src/guardian/pages/show_qr_code_page.dart b/lib/src/guardian/pages/show_qr_code_page.dart new file mode 100644 index 00000000..4553bb6a --- /dev/null +++ b/lib/src/guardian/pages/show_qr_code_page.dart @@ -0,0 +1,114 @@ +import 'dart:math'; +import 'package:wakelock/wakelock.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:share_plus/share_plus.dart'; + +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/icon_of.dart'; +import '/src/core/model/core_model.dart'; + +class ShowQRCodePage extends StatefulWidget { + final QRCode qrCode; + + const ShowQRCodePage({super.key, required this.qrCode}); + + @override + State createState() => _ShowQRCodePageState(); +} + +class _ShowQRCodePageState extends State { + static const _qrSize = 360.0; + var _logoSize = 72.0; + + @override + void initState() { + super.initState(); + Wakelock.enable(); + } + + @override + void didChangeDependencies() { + _logoSize = min(_qrSize, MediaQuery.of(context).size.width) / 5; + super.didChangeDependencies(); + } + + @override + void dispose() { + Wakelock.disable(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Column(children: [ + // Header + const HeaderBar( + caption: 'My QR Code', + closeButton: HeaderBarCloseButton(), + ), + // Body + Padding( + padding: paddingAll20, + child: Text( + 'This is a one-time QR Code.' + ' Show it to the Owner of the Recovery Group.', + textAlign: TextAlign.center, + style: textStyleSourceSansPro416, + ), + ), + // QR Code + Padding( + padding: paddingH20, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: _qrSize, + maxWidth: _qrSize, + ), + child: QrImageView( + gapless: false, + padding: paddingAll20, + backgroundColor: clSurface, + foregroundColor: Colors.white, + embeddedImageStyle: QrEmbeddedImageStyle( + size: Size.square(_logoSize), + ), + embeddedImage: const AssetImage( + 'assets/images/logo_qr.png', + ), + dataModuleStyle: const QrDataModuleStyle( + color: clPurpleLight, + dataModuleShape: QrDataModuleShape.circle, + ), + eyeStyle: const QrEyeStyle(eyeShape: QrEyeShape.circle), + errorCorrectionLevel: QrErrorCorrectLevel.M, + data: widget.qrCode.toBase64url(), + ), + // ), + ), + ), + ), + // Share Button + Container( + padding: paddingAll20, + width: _qrSize + 40, // padding 20+20 + child: Builder( + builder: (BuildContext context) => ElevatedButton.icon( + icon: const IconOf.share(bgColor: clIndigo500, size: 20), + label: const Text('Share Code'), + onPressed: () async { + final box = context.findRenderObject() as RenderBox?; + await Share.share( + 'This is a SINGLE-USE authentication token for Guardian Keyper.' + ' DO NOT REUSE IT! \n ${widget.qrCode.toBase64url()}', + subject: 'Guardian Code', + sharePositionOrigin: + box!.localToGlobal(Offset.zero) & box.size, + ); + if (mounted) Navigator.of(context).pop(); + }, + ), + ), + ), + ]); +} diff --git a/lib/src/guardian/widgets/guardian_dashboard_button_widget.dart b/lib/src/guardian/widgets/guardian_dashboard_button_widget.dart new file mode 100644 index 00000000..18faabd9 --- /dev/null +++ b/lib/src/guardian/widgets/guardian_dashboard_button_widget.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +import '/src/core/theme_data.dart'; + +import '../guardian_controller.dart'; + +class GuardianDashboardButtonWidget extends StatelessWidget { + const GuardianDashboardButtonWidget({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Provider.of(context); + return Container( + decoration: BoxDecoration(borderRadius: borderRadius, color: clYellow), + padding: paddingAll8, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Stored Shards', + style: textStyleSourceSansPro612.copyWith(color: clBlack), + ), + Text( + '${controller.secretShards.length} Shards', + style: textStylePoppins616.copyWith(color: clBlack), + ), + ], + ), + const Icon(Icons.arrow_forward_ios_outlined, color: clBlack), + ], + ), + ); + } +} diff --git a/lib/src/guardian/widgets/message_action_widget.dart b/lib/src/guardian/widgets/message_action_widget.dart new file mode 100644 index 00000000..d2d34258 --- /dev/null +++ b/lib/src/guardian/widgets/message_action_widget.dart @@ -0,0 +1,205 @@ +import 'dart:async'; + +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/icon_of.dart'; +import '/src/core/model/core_model.dart'; + +import '../guardian_controller.dart'; + +class MessageActionWidget extends StatefulWidget { + final String title; + final MessageModel message; + + const MessageActionWidget({ + super.key, + required this.title, + required this.message, + }); + + @override + State createState() => _MessageActionWidgetState(); +} + +class _MessageActionWidgetState extends State + with TickerProviderStateMixin { + static const _subtitles = { + OperationType.authPeer: ' asks you to become a Guardian for ', + OperationType.setShard: ' asks you to accept the Secret Shard for ', + OperationType.getShard: ' asks you to approve a recovery of Secret for ', + OperationType.takeOwnership: + ' asks you to approve a change of ownership for ', + }; + late final StreamSubscription _peerStatusSubscription; + late final AnimationController _animationController; + bool _isPeerOnline = false; + bool _isRequestError = false; + bool _isRequestActive = false; + + @override + void initState() { + _animationController = AnimationController( + vsync: this, + duration: const Duration(seconds: 3), + )..addListener(() => setState(() {})); + + final networkService = + context.read().diContainer.networkService; + + networkService + .pingPeer(peerId: widget.message.peerId) + .then((isOnline) => setState(() => _isPeerOnline = isOnline)); + + _peerStatusSubscription = networkService.onPeerStatusChanged( + (isOnline) => setState(() => _isPeerOnline = isOnline), + widget.message.peerId, + ); + super.initState(); + } + + @override + void dispose() { + _peerStatusSubscription.cancel(); + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => BottomSheetWidget( + titleString: widget.title, + textSpan: [ + TextSpan( + text: widget.message.secretShard.ownerName, + style: textStyleSourceSansPro616, + ), + TextSpan( + text: _subtitles[widget.message.type]!, + ), + TextSpan( + text: widget.message.secretShard.groupName, + style: textStyleSourceSansPro616, + ), + ], + body: Padding( + padding: paddingV20, + child: Container( + decoration: boxDecoration, + padding: paddingAll20, + height: 120, + child: _isRequestError + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Connection Error', + style: textStyleSourceSansPro616), + Padding( + padding: paddingV12, + child: Text( + 'Something went wrong. Please try again.', + style: textStyleSourceSansPro416Purple, + ), + ), + LinearProgressIndicator( + value: _animationController.value, + ), + ], + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const IconOf.shield(color: clWhite), + Padding( + padding: paddingTop12, + child: _isPeerOnline + ? Text('Online', + style: textStyleSourceSansPro612.copyWith( + color: clGreen)) + : Text('Offline', + style: textStyleSourceSansPro612.copyWith( + color: clRed)), + ), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 20), + child: RichText( + softWrap: true, + text: TextSpan( + style: textStyleSourceSansPro416Purple, + children: [ + const TextSpan( + text: 'To approve or reject the request,' + ' both users must run the app ', + ), + TextSpan( + text: 'at the same time', + style: textStyleSourceSansPro616, + ), + TextSpan( + text: '. Ask ' + '${widget.message.secretShard.ownerName}' + ' to log in.', + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + footer: Row(children: [ + Expanded( + child: ElevatedButton( + onPressed: _isPeerOnline && !_isRequestError && !_isRequestActive + ? () async => await _sendRespone(MessageStatus.rejected) + : null, + child: const Text('Reject'), + )), + const SizedBox(width: 10), + Expanded( + child: PrimaryButton( + onPressed: _isPeerOnline && !_isRequestError && !_isRequestActive + ? () async => await _sendRespone(MessageStatus.accepted) + : null, + text: 'Approve', + )), + ]), + ); + + Future _sendRespone(MessageStatus status) async { + final response = widget.message.copyWith(status: status); + setState(() => _isRequestActive = true); + try { + final controller = context.read(); + switch (response.type) { + case OperationType.authPeer: + await controller.sendAuthPeerResponse(response); + break; + case OperationType.setShard: + await controller.sendSetShardResponse(response); + break; + case OperationType.getShard: + await controller.sendGetShardResponse(response); + break; + case OperationType.takeOwnership: + await controller.sendTakeOwnershipResponse(response); + break; + } + if (mounted) Navigator.of(context).pop(); + } catch (_) { + _animationController + .forward() + .then((_) => setState(() => _isRequestError = false)); + setState(() { + _isRequestError = true; + _isRequestActive = false; + }); + } + } +} diff --git a/lib/src/guardian/widgets/message_list_tile_widget.dart b/lib/src/guardian/widgets/message_list_tile_widget.dart new file mode 100644 index 00000000..697467eb --- /dev/null +++ b/lib/src/guardian/widgets/message_list_tile_widget.dart @@ -0,0 +1,97 @@ +import '/src/core/theme_data.dart'; +import '/src/core/widgets/misc.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/icon_of.dart'; +import '/src/core/model/core_model.dart'; + +import 'message_action_widget.dart'; +import 'request_card_widget.dart'; + +class MessageListTile extends StatelessWidget { + static const _icons = { + OperationType.authPeer: IconOf.shield(color: clWhite), + OperationType.setShard: IconOf.splitAndShare(), + OperationType.getShard: IconOf.secret(), + OperationType.takeOwnership: IconOf.owner(), + }; + + static const _titles = { + OperationType.authPeer: 'Guardian Approval Request', + OperationType.setShard: 'Accept the Secret Shard', + OperationType.getShard: 'Secret Recovery Request', + OperationType.takeOwnership: 'Ownership Change Request', + }; + + static String roundedAgo(DateTime value) { + const hoursInMonth = 24 * 30; + const hoursInYear = 24 * 30 * 365; + final diff = DateTime.now().difference(value); + if (diff.inMinutes == 0) return 'just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}min ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inHours < hoursInMonth) return '${diff.inHours ~/ 24}d ago'; + return diff.inHours < hoursInYear + ? '${diff.inHours ~/ hoursInMonth}mon ago' + : '${diff.inHours ~/ hoursInYear}y ago'; + } + + static Future showActiveMessage( + BuildContext context, + MessageModel message, + ) => + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => MessageActionWidget( + title: _titles[message.type]!, + message: message, + ), + ); + + final MessageModel message; + + const MessageListTile({super.key, required this.message}); + + @override + Widget build(BuildContext context) => ListTile( + minLeadingWidth: 20, + leading: _icons[message.type], + trailing: const Icon(Icons.arrow_forward_ios_rounded), + title: Row( + children: [ + Text( + _titles[message.type]!, + style: textStyleSourceSansPro614, + ), + if (message.isProcessed) + const Padding( + padding: EdgeInsets.only(left: 4), + child: DotColored(color: clRed), + ), + ], + ), + subtitle: Text( + '${roundedAgo(message.timestamp)} · from ${message.secretShard.ownerName}', + style: textStyleSourceSansPro414Purple, + ), + onTap: message.isProcessed + ? () => showActiveMessage(context, message) + : () => _showResolvedMessage(context, message), + ); + + Future _showResolvedMessage( + BuildContext context, + MessageModel message, + ) => + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => BottomSheetWidget( + titleString: _titles[message.type]!, + body: Padding( + padding: paddingV20, + child: RequestCardWidget(message: message), + ), + ), + ); +} diff --git a/lib/src/guardian/widgets/notification_icon_widget.dart b/lib/src/guardian/widgets/notification_icon_widget.dart new file mode 100644 index 00000000..c06a455a --- /dev/null +++ b/lib/src/guardian/widgets/notification_icon_widget.dart @@ -0,0 +1,41 @@ +import '/src/core/theme_data.dart'; +import '/src/core/widgets/misc.dart'; +import '/src/core/widgets/icon_of.dart'; + +import '../guardian_controller.dart'; + +class NotificationIconWidget extends StatelessWidget { + final bool isSelected; + + const NotificationIconWidget({super.key, this.isSelected = false}); + + const NotificationIconWidget.selected({super.key}) : isSelected = true; + + @override + Widget build(BuildContext context) { + final controller = Provider.of(context); + final count = controller.messagesProcessed.length; + return Stack( + clipBehavior: Clip.none, + children: [ + isSelected + ? const IconOf.navBarBellSelected() + : const IconOf.navBarBell(), + if (count > 0) + Positioned( + top: -3, + right: -3, + child: DotColored( + color: clRed, + size: 17, + child: Center( + child: Text( + count > 9 ? '$count+' : count.toString(), + style: textStyleSourceSansPro612.copyWith(color: clIndigo900), + )), + ), + ), + ], + ); + } +} diff --git a/lib/src/guardian/widgets/request_card_widget.dart b/lib/src/guardian/widgets/request_card_widget.dart new file mode 100644 index 00000000..67c14594 --- /dev/null +++ b/lib/src/guardian/widgets/request_card_widget.dart @@ -0,0 +1,105 @@ +import 'package:intl/intl.dart'; + +import '/src/core/theme_data.dart'; +import '/src/core/widgets/misc.dart'; +import '/src/core/model/core_model.dart'; + +class RequestCardWidget extends StatelessWidget { + final MessageModel message; + + const RequestCardWidget({super.key, required this.message}); + + @override + Widget build(BuildContext context) => Container( + decoration: boxDecoration, + padding: paddingAll20, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: paddingBottom20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('FROM', style: textStyleSourceSansPro612Purple), + Text( + message.secretShard.ownerName, + style: textStyleSourceSansPro616, + ), + ], + ), + ), + Padding( + padding: paddingBottom20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('KEY', style: textStyleSourceSansPro612Purple), + Text( + message.secretShard.ownerId.toHexShort(), + style: textStyleSourceSansPro616, + ), + ], + ), + ), + Padding( + padding: paddingBottom20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('GROUP NAME', style: textStyleSourceSansPro612Purple), + Text( + message.secretShard.groupName, + style: textStyleSourceSansPro616, + ), + ], + ), + ), + Padding( + padding: paddingBottom20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('DATE', style: textStyleSourceSansPro612Purple), + Text( + '${DateFormat(DateFormat.HOUR24_MINUTE_SECOND).format(message.timestamp)} ' + ' ${DateFormat(DateFormat.YEAR_ABBR_MONTH_DAY).format(message.timestamp)}', + style: textStyleSourceSansPro616, + ), + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('STATUS', style: textStyleSourceSansPro612Purple), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: message.isAccepted + ? clGreen.withAlpha(56) + : clRed.withAlpha(56), + ), + padding: const EdgeInsets.all(4), + child: Row( + children: [ + Text( + message.isAccepted ? ' Approved' : ' Rejected', + style: textStyleSourceSansPro616, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: DotColored( + color: message.isAccepted ? clGreen : clRed, + size: 10, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); +} diff --git a/lib/src/guardian/widgets/secret_list_tile_widget.dart b/lib/src/guardian/widgets/secret_list_tile_widget.dart new file mode 100644 index 00000000..d8eaf778 --- /dev/null +++ b/lib/src/guardian/widgets/secret_list_tile_widget.dart @@ -0,0 +1,113 @@ +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/icon_of.dart'; +import '/src/core/model/core_model.dart'; + +import '../guardian_controller.dart'; +import '../pages/show_qr_code_page.dart'; + +class SecretTileWidget extends StatelessWidget { + final SecretShardModel secretShard; + final bool isExpanded; + final void Function(GroupId? groupId)? setExpanded; + + const SecretTileWidget({ + super.key, + required this.secretShard, + this.isExpanded = false, + this.setExpanded, + }); + + @override + Widget build(BuildContext context) => Container( + decoration: BoxDecoration( + borderRadius: borderRadius, + border: Border.all(color: clSurface), + ), + clipBehavior: Clip.hardEdge, + foregroundDecoration: BoxDecoration( + borderRadius: borderRadius, + border: Border.all(color: clSurface, width: 2), + ), + child: ExpansionTile( + key: Key(secretShard.groupId.asHex), + initiallyExpanded: isExpanded, + onExpansionChanged: (isExpanded) => + setExpanded?.call(isExpanded ? secretShard.groupId : null), + leading: const IconOf.shield(color: clWhite), + title: Text( + secretShard.groupName, + style: textStylePoppins616, + ), + subtitle: RichText( + textAlign: TextAlign.left, + overflow: TextOverflow.clip, + text: TextSpan( + children: [ + TextSpan( + text: 'Owner: ', + style: textStyleSourceSansPro414Purple, + ), + TextSpan( + text: secretShard.ownerName, + style: textStyleSourceSansPro614, + ), + ], + ), + ), + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('OWNER ID', style: textStyleSourceSansPro612Purple), + Text( + secretShard.ownerId.toHexShort(), + style: textStyleSourceSansPro414, + ), + ], + ), + Padding( + padding: paddingTop20, + child: PrimaryButton( + text: 'Change Owner', + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _ConfirmChangeOwnershipDialogWidget( + secretShard: secretShard, + ), + ), + ), + ), + ], + ), + ); +} + +class _ConfirmChangeOwnershipDialogWidget extends StatelessWidget { + final SecretShardModel secretShard; + + const _ConfirmChangeOwnershipDialogWidget({required this.secretShard}); + + @override + Widget build(BuildContext context) => BottomSheetWidget( + icon: const IconOf.owner(isBig: true, bage: BageType.warning), + titleString: 'Change Owner', + textString: 'Are you sure you want to change owner' + ' for ${secretShard.groupName} group?' + ' This action cannot be undone.', + footer: PrimaryButton( + text: 'Confirm', + onPressed: () => Navigator.of(context).pushReplacement( + MaterialPageRoute( + fullscreenDialog: true, + maintainState: false, + builder: (BuildContext context) => ScaffoldWidget( + child: ShowQRCodePage( + qrCode: context + .read() + .getQrCode(secretShard)))), + ), + ), + ); +} diff --git a/lib/src/home/home_controller.dart b/lib/src/home/home_controller.dart new file mode 100644 index 00000000..630fd92f --- /dev/null +++ b/lib/src/home/home_controller.dart @@ -0,0 +1,7 @@ +import '/src/core/controller/page_controller.dart'; + +export 'package:provider/provider.dart'; + +class HomeController extends PageController { + HomeController({required super.diContainer, required super.pagesCount}); +} diff --git a/lib/src/home/home_view.dart b/lib/src/home/home_view.dart new file mode 100644 index 00000000..47d422ae --- /dev/null +++ b/lib/src/home/home_view.dart @@ -0,0 +1,115 @@ +import 'dart:async'; + +import '/src/core/di_container.dart'; +import '/src/core/model/core_model.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/icon_of.dart'; + +import '/src/guardian/pages/message_page.dart'; +import '/src/guardian/pages/managed_secrets_page.dart'; +import '/src/guardian/widgets/message_list_tile_widget.dart'; +import '/src/guardian/widgets/notification_icon_widget.dart'; +import '/src/recovery_group/pages/managed_groups_page.dart'; + +import 'home_controller.dart'; +import 'pages/dashboard_page.dart'; + +class HomeView extends StatefulWidget { + static const routeName = '/home'; + static const pages = [ + DashboardPage(), + ManagedGroupsPage(), + ManagedSecretsPage(), + MessagesPage(), + ]; + + const HomeView({super.key}); + + @override + State createState() => _HomeViewState(); + + static int getPageNumber() { + for (var i = 0; i < pages.length; i++) { + if (pages[i] is T) return i; + } + return 0; + } +} + +class _HomeViewState extends State with WidgetsBindingObserver { + late final StreamSubscription _subscription; + bool _hasModal = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _subscription = context + .read() + .eventBus + .on() + .listen((NewMessageProcessedEvent event) { + if (_hasModal || !event.message.isProcessed) return; + _hasModal = true; + MessageListTile.showActiveMessage(context, event.message) + .then((_) => _hasModal = false); + }); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) async { + if (state != AppLifecycleState.resumed) { + final diContainer = context.read(); + await diContainer.boxSettings.flush(); + await diContainer.boxSecretShards.flush(); + await diContainer.boxRecoveryGroup.flush(); + await diContainer.boxMessages.flush(); + } + } + + @override + void dispose() { + _subscription.cancel(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ChangeNotifierProvider( + create: (context) => HomeController( + pagesCount: HomeView.pages.length, + diContainer: context.read(), + ), + child: Consumer( + builder: (_, controller, __) => ScaffoldWidget( + bottomNavigationBar: BottomNavigationBar( + currentIndex: controller.currentPage, + items: const [ + BottomNavigationBarItem( + icon: IconOf.navBarHome(), + activeIcon: IconOf.navBarHomeSelected(), + label: 'Home', + ), + BottomNavigationBarItem( + icon: IconOf.navBarKey(), + activeIcon: IconOf.navBarKeySelected(), + label: 'My secrets', + ), + BottomNavigationBarItem( + icon: IconOf.navBarShield(), + activeIcon: IconOf.navBarShieldSelected(), + label: 'Shards', + ), + BottomNavigationBarItem( + icon: NotificationIconWidget(), + activeIcon: NotificationIconWidget.selected(), + label: 'Messages', + ), + ], + onTap: (value) => controller.gotoScreen(value), + ), + child: HomeView.pages[controller.currentPage], + ), + ), + ); +} diff --git a/lib/src/home/pages/dashboard_page.dart b/lib/src/home/pages/dashboard_page.dart new file mode 100644 index 00000000..abe4a4f2 --- /dev/null +++ b/lib/src/home/pages/dashboard_page.dart @@ -0,0 +1,72 @@ +import '/src/core/di_container.dart'; +import '/src/core/model/core_model.dart'; +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/recovery_group/restore_group/restore_group_view.dart'; + +import 'settings_page.dart'; +import '../widgets/copy_my_key_to_clipboard_widget.dart'; +import '../widgets/dashboard_groups_widget.dart'; +import '../widgets/my_qr_code_widget.dart'; + +class DashboardPage extends StatelessWidget { + const DashboardPage({super.key}); + + @override + Widget build(BuildContext context) { + final diContainer = context.read(); + return ListView( + padding: paddingH20, + children: [ + const Padding(padding: paddingTop20), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ValueListenableBuilder>( + valueListenable: diContainer.boxSettings.listenable(), + builder: (_, boxSettings, __) => Text( + boxSettings.deviceName, + style: textStylePoppins620, + ), + ), + Row( + children: [ + Text( + diContainer.myPeerId.toHexShort(), + style: textStyleSourceSansPro414Purple, + ), + Expanded( + child: Container( + alignment: Alignment.centerLeft, + child: const CopyMyKeyToClipboardWidget(), + )), + IconButton( + onPressed: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => + const ScaffoldWidget(child: SettingsPage()))), + icon: const Icon(Icons.settings_outlined, color: clWhite), + ) + ], + ), + ], + ), + const Padding( + padding: paddingTop20, + child: MyQRCodeWidget(), + ), + const Padding( + padding: paddingTop20, + child: DashboardGroupsWidget(), + ), + Padding( + padding: paddingV20, + child: OutlinedButton( + onPressed: () => + Navigator.of(context).pushNamed(RestoreGroupView.routeName), + child: const Text('Restore Group'), + ), + ), + ], + ); + } +} diff --git a/lib/src/home/pages/set_device_name_page.dart b/lib/src/home/pages/set_device_name_page.dart new file mode 100644 index 00000000..0071eb98 --- /dev/null +++ b/lib/src/home/pages/set_device_name_page.dart @@ -0,0 +1,78 @@ +import '/src/core/di_container.dart'; +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; + +class SetDeviceNamePage extends StatefulWidget { + const SetDeviceNamePage({super.key}); + + @override + State createState() => _SetDeviceNamePageState(); +} + +class _SetDeviceNamePageState extends State { + late final DIContainer diContainer; + String _name = ''; + + @override + void initState() { + super.initState(); + diContainer = context.read(); + _name = diContainer.boxSettings.deviceName; + } + + @override + Widget build(BuildContext context) => Column( + children: [ + // Header + const HeaderBar( + caption: 'Change Device Name', + backButton: HeaderBarBackButton(), + ), + // Body + Expanded( + child: ListView( + padding: paddingAll20, + children: [ + Padding( + padding: paddingAll20, + child: Text( + 'Create new Guardian name', + textAlign: TextAlign.center, + style: textStylePoppins620, + ), + ), + Padding( + padding: paddingV20, + child: TextFormField( + autofocus: true, + initialValue: _name, + keyboardType: TextInputType.text, + maxLength: diContainer.globals.maxNameLength, + onChanged: (value) => setState(() => _name = value), + decoration: const InputDecoration( + labelText: ' Guardian name ', + helperText: 'Minimum 3 characters', + ), + )), + // Footer + Padding( + padding: paddingV20, + child: PrimaryButton( + text: 'Proceed', + onPressed: _name.length < diContainer.globals.minNameLength + ? null + : () { + diContainer.boxSettings.deviceName = _name; + ScaffoldMessenger.of(context).showSnackBar( + buildSnackBar( + text: + 'Device name was changed successfully.')); + Navigator.of(context).pop(); + }, + ), + ), + ], + )), + ], + ); +} diff --git a/lib/src/home/pages/settings_connection_page.dart b/lib/src/home/pages/settings_connection_page.dart new file mode 100644 index 00000000..a2f2ab37 --- /dev/null +++ b/lib/src/home/pages/settings_connection_page.dart @@ -0,0 +1,151 @@ +// import '/src/core/di_container.dart'; +// import '/src/core/model/core_model.dart'; +// import '/src/core/theme_data.dart'; +// import '/src/core/widgets/common.dart'; + +// class SettingsConnectionPage extends StatelessWidget { +// const SettingsConnectionPage({super.key}); + +// @override +// Widget build(BuildContext context) { +// final boxSettings = context.read().boxSettings; +// return Column( +// children: [ +// // Header +// const HeaderBar( +// caption: 'Connection', +// backButton: HeaderBarBackButton(), +// ), +// // Body +// Expanded( +// child: ListView( +// padding: paddingAll20, +// children: [ +// const Padding( +// padding: paddingV12, +// child: Text('Device Discovery', style: textStyleBold), +// ), +// //No discover methods message +// Visibility( +// visible: !boxSettings.isBonjourDiscoverEnabled && +// !boxSettings.isProxyDiscoverEnabled, +// child: Padding( +// padding: paddingBottom12, +// child: Text( +// 'We advice you to enable at least one method' +// ' to avoid any issues with device discovery.', +// style: +// TextStyle(color: Theme.of(context).colorScheme.error), +// ), +// )), +// // Use Bonjour Discover +// Padding( +// padding: paddingBottom12, +// child: ValueListenableBuilder>( +// valueListenable: boxSettings.listenable(), +// builder: (_, boxSettings, __) => SwitchListTile.adaptive( +// contentPadding: const EdgeInsets.symmetric( +// horizontal: 20, vertical: 12), +// title: const Text('Bonjour Discover'), +// subtitle: const Text( +// 'Directly discover devices in local network (e.g. Wi-Fi)'), +// value: boxSettings.isBonjourDiscoverEnabled, +// onChanged: (value) { +// boxSettings.isBonjourDiscoverEnabled = value; +// if (!boxSettings.isProxyDiscoverEnabled) { +// Navigator.of(context).pushReplacement( +// MaterialPageRoute( +// builder: (_) => const ScaffoldWidget( +// child: SettingsConnectionPage()))); +// } +// }), +// ), +// ), +// //Use Proxy Discover +// Padding( +// padding: paddingBottom32, +// child: ValueListenableBuilder>( +// valueListenable: boxSettings.listenable(), +// builder: (_, boxSettings, __) => SwitchListTile.adaptive( +// contentPadding: const EdgeInsets.symmetric( +// horizontal: 20, vertical: 12), +// title: const Text('Proxy Discover'), +// subtitle: +// const Text('Discover devices in Internet through' +// ' the Guardian Keyper proxy server'), +// value: boxSettings.isProxyDiscoverEnabled, +// onChanged: (value) { +// boxSettings.isProxyDiscoverEnabled = value; +// if (!boxSettings.isBonjourDiscoverEnabled) { +// Navigator.of(context).pushReplacement( +// MaterialPageRoute( +// builder: (_) => const ScaffoldWidget( +// child: SettingsConnectionPage()))); +// } +// }), +// ), +// ), +// const Padding( +// padding: paddingBottom12, +// child: Text('Data Transfer', style: textStyleBold), +// ), +// //No data transfer methods message +// Visibility( +// visible: !boxSettings.isDirectDataTransferEnabled && +// !boxSettings.isProxyDataTransferEnabled, +// child: Padding( +// padding: paddingBottom12, +// child: Text( +// 'We advice you to enable at least one method' +// ' to avoid any issues with data transfer.', +// style: +// TextStyle(color: Theme.of(context).colorScheme.error), +// ), +// )), +// //Use Direct Data Transfer +// Padding( +// padding: paddingBottom12, +// child: ValueListenableBuilder>( +// valueListenable: boxSettings.listenable(), +// builder: (_, boxSettings, __) => SwitchListTile.adaptive( +// title: const Text('Direct Data Transfer'), +// subtitle: +// const Text('Transfer data using direct connection'), +// value: boxSettings.isDirectDataTransferEnabled, +// onChanged: (value) { +// boxSettings.isDirectDataTransferEnabled = value; +// if (!boxSettings.isProxyDataTransferEnabled) { +// Navigator.of(context).pushReplacement( +// MaterialPageRoute( +// builder: (_) => const ScaffoldWidget( +// child: SettingsConnectionPage()))); +// } +// }), +// ), +// ), +// //Use Proxy Discover Data Transfer +// ValueListenableBuilder>( +// valueListenable: boxSettings.listenable(), +// builder: (_, settings, __) => SwitchListTile.adaptive( +// contentPadding: const EdgeInsets.symmetric( +// horizontal: 20, vertical: 12), +// title: const Text('Proxy Discover'), +// subtitle: const Text( +// 'Transfer data through the Guardian Keyper proxy server'), +// value: settings.isProxyDataTransferEnabled, +// onChanged: (value) { +// boxSettings.isProxyDataTransferEnabled = value; +// if (!boxSettings.isDirectDataTransferEnabled) { +// Navigator.of(context).pushReplacement(MaterialPageRoute( +// builder: (_) => const ScaffoldWidget( +// child: SettingsConnectionPage()))); +// } +// }), +// ), +// ], +// ), +// ), +// ], +// ); +// } +// } diff --git a/lib/src/home/pages/settings_page.dart b/lib/src/home/pages/settings_page.dart new file mode 100644 index 00000000..cb1ba767 --- /dev/null +++ b/lib/src/home/pages/settings_page.dart @@ -0,0 +1,75 @@ +import '/src/core/di_container.dart'; +import '/src/core/model/core_model.dart'; +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/icon_of.dart'; + +import 'set_device_name_page.dart'; +import '../widgets/change_passcode_widget.dart'; +import '../widgets/hidden_settings_widget.dart'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context) { + final diContainer = context.read(); + return Column( + children: [ + // Header + const HeaderBar( + caption: 'Settings', + closeButton: HeaderBarCloseButton(), + ), + // Body + Expanded( + child: ListView( + padding: paddingAll20, + children: [ + // Change Device Name + Padding( + padding: paddingV6, + child: ListTile( + leading: const IconOf.shardOwner(), + title: const Text('Change Guardian name'), + subtitle: ValueListenableBuilder>( + valueListenable: diContainer.boxSettings.listenable(), + builder: (_, boxSettings, __) => Text( + boxSettings.deviceName, + style: textStyleSourceSansPro414Purple, + ), + ), + trailing: const Icon(Icons.arrow_forward_ios_rounded), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => + const ScaffoldWidget(child: SetDeviceNamePage()))), + ), + ), + // Change PassCode + const Padding(padding: paddingV6, child: ChangePassCodeWidget()), + // Use Biometrics + if (diContainer.platformService.hasBiometrics) + Padding( + padding: paddingV6, + child: ValueListenableBuilder>( + valueListenable: diContainer.boxSettings.listenable(), + builder: (_, boxSettings, __) => SwitchListTile.adaptive( + secondary: const IconOf.biometricLogon(), + title: const Text('Biometric login'), + subtitle: const Text( + 'Easier, faster authentication with biometry'), + value: boxSettings.isBiometricsEnabled, + onChanged: (value) => + diContainer.boxSettings.isBiometricsEnabled = value, + ), + ), + ), + // Shows hidden settings + const Padding(padding: paddingV6, child: HiddenSettingsWidget()), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/home/widgets/change_passcode_widget.dart b/lib/src/home/widgets/change_passcode_widget.dart new file mode 100644 index 00000000..142e8bbf --- /dev/null +++ b/lib/src/home/widgets/change_passcode_widget.dart @@ -0,0 +1,105 @@ +import '/src/core/di_container.dart'; +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/icon_of.dart'; +import '/src/core/widgets/auth.dart'; + +class ChangePassCodeWidget extends StatelessWidget { + const ChangePassCodeWidget({super.key}); + + @override + Widget build(BuildContext context) => ListTile( + leading: const IconOf.passcode(), + title: const Text('Passcode'), + subtitle: Text( + 'Change authentication passcode', + style: textStyleSourceSansPro414Purple, + ), + trailing: const Icon(Icons.arrow_forward_ios_rounded), + onTap: () { + final diContainer = context.read(); + screenLock( + context: context, + correctString: diContainer.boxSettings.passCode, + canCancel: true, + digits: diContainer.boxSettings.passCode.length, + secretsConfig: secretsConfig, + keyPadConfig: keyPadConfig, + title: Padding( + padding: paddingV32H20, + child: Text( + 'Please enter your current passcode', + style: textStylePoppins620, + textAlign: TextAlign.center, + )), + didUnlocked: () { + Navigator.of(context).pop(); + _enterNewPincode(context); + }, + didError: (_) async { + ScaffoldMessenger.of(context).showSnackBar(buildSnackBar( + text: 'Wrong passcode!', + duration: const Duration(seconds: 2), + isFloating: true, + isError: true, + )); + await diContainer.platformService.vibrate(); + }, + ); + }, + ); + + void _enterNewPincode(BuildContext context) { + final diContainer = context.read(); + screenLock( + context: context, + correctString: '', + canCancel: true, + confirmation: true, + digits: diContainer.globals.passCodeLength, + keyPadConfig: keyPadConfig, + secretsConfig: secretsConfig, + screenLockConfig: screenLockConfig, + title: Padding( + padding: paddingV32H20, + child: Text( + 'Please create your new passcode', + style: textStylePoppins620, + textAlign: TextAlign.center, + )), + confirmTitle: Padding( + padding: paddingV32H20, + child: Text( + 'Please repeate your new passcode', + style: textStylePoppins620, + textAlign: TextAlign.center, + )), + didConfirmed: (value) { + diContainer.boxSettings.passCode = value; + Navigator.of(context).pop(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (BuildContext context) => BottomSheetWidget( + icon: const IconOf.secrets(isBig: true, bage: BageType.ok), + titleString: 'Passcode changed', + textString: 'Your login passcode was changed successfully.', + footer: PrimaryButton( + text: 'Done', + onPressed: Navigator.of(context).pop, + ), + ), + ); + }, + didError: (_) async { + ScaffoldMessenger.of(context).showSnackBar(buildSnackBar( + text: 'Wrong passcode!', + duration: const Duration(seconds: 2), + isFloating: true, + isError: true, + )); + await diContainer.platformService.vibrate(); + }, + ); + } +} diff --git a/lib/src/home/widgets/copy_my_key_to_clipboard_widget.dart b/lib/src/home/widgets/copy_my_key_to_clipboard_widget.dart new file mode 100644 index 00000000..5729e6a3 --- /dev/null +++ b/lib/src/home/widgets/copy_my_key_to_clipboard_widget.dart @@ -0,0 +1,45 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; + +import '/src/core/di_container.dart'; +import '/src/core/widgets/common.dart'; + +class CopyMyKeyToClipboardWidget extends StatefulWidget { + const CopyMyKeyToClipboardWidget({super.key}); + + @override + State createState() => + _CopyMyKeyToClipboardWidgetState(); +} + +class _CopyMyKeyToClipboardWidgetState + extends State { + Timer? timer; + var isDisabled = false; + + @override + Widget build(BuildContext context) { + final diContainer = context.read(); + return IconButton( + icon: const Icon(Icons.copy, size: 20), + onPressed: isDisabled + ? null + : () async { + await Clipboard.setData( + ClipboardData(text: diContainer.myPeerId.asHex)); + setState(() => isDisabled = true); + timer = Timer( + diContainer.globals.snackBarDuration, + () => setState(() => isDisabled = false), + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + buildSnackBar( + text: 'Public Key has been copied to clipboard.', + ), + ); + } + }, + ); + } +} diff --git a/lib/src/home/widgets/dashboard_groups_widget.dart b/lib/src/home/widgets/dashboard_groups_widget.dart new file mode 100644 index 00000000..24fb2f26 --- /dev/null +++ b/lib/src/home/widgets/dashboard_groups_widget.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +import '/src/core/theme_data.dart'; +import '/src/guardian/pages/managed_secrets_page.dart'; +import '/src/guardian/widgets/guardian_dashboard_button_widget.dart'; +import '/src/recovery_group/pages/managed_groups_page.dart'; +import '/src/recovery_group/widgets/recovery_group_dashboard_button_widget.dart'; + +import '../home_controller.dart'; +import '../home_view.dart'; + +class DashboardGroupsWidget extends StatelessWidget { + const DashboardGroupsWidget({super.key}); + + @override + Widget build(BuildContext context) => Container( + decoration: boxDecoration, + padding: paddingAll20, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: paddingBottom12, + child: Text( + 'Recovery Groups', + textAlign: TextAlign.left, + style: textStylePoppins616, + ), + ), + Padding( + padding: paddingBottom20, + child: Text( + 'Create a Recovery Group to secure your secret with the help of your Guardians.', + textAlign: TextAlign.left, + style: textStyleSourceSansPro416.copyWith(color: clPurpleLight), + ), + ), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: GestureDetector( + onTap: () => context + .read() + .gotoScreen(HomeView.getPageNumber()), + child: const RecoveryGroupDashboardButtonWidget(), + )), + const SizedBox(width: 10), + Expanded( + child: GestureDetector( + onTap: () => context + .read() + .gotoScreen(HomeView.getPageNumber()), + child: const GuardianDashboardButtonWidget(), + )), + ], + ), + ], + ), + ); +} diff --git a/lib/src/home/widgets/hidden_settings_widget.dart b/lib/src/home/widgets/hidden_settings_widget.dart new file mode 100644 index 00000000..bee015bc --- /dev/null +++ b/lib/src/home/widgets/hidden_settings_widget.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '/src/core/theme_data.dart'; +import '/src/core/widgets/icon_of.dart'; +import '/src/core/di_container.dart'; +import '/src/core/model/core_model.dart'; + +class HiddenSettingsWidget extends StatefulWidget { + const HiddenSettingsWidget({super.key}); + + @override + State createState() => _HiddenSettingsWidgetState(); +} + +class _HiddenSettingsWidgetState extends State { + late DIContainer _diContainer; + int _counter = 0; + bool _isVisible = false; + + @override + void initState() { + _diContainer = context.read(); + super.initState(); + } + + @override + Widget build(BuildContext context) => GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + _counter++; + if (_counter > 5) { + _counter = 0; + setState(() => _isVisible = true); + } + }, + child: Visibility( + maintainSize: true, + maintainAnimation: true, + maintainState: true, + maintainInteractivity: false, + visible: _isVisible, + child: Card( + child: Column( + children: [ + // Toggle Proxy + Padding( + padding: paddingV12, + child: ValueListenableBuilder>( + valueListenable: _diContainer.boxSettings.listenable(), + builder: (_, boxSettings, __) => SwitchListTile.adaptive( + secondary: const IconOf.splitAndShare(), + title: const Text('Proxy connection'), + subtitle: const Text( + 'Connect through Keyper-operated proxy server'), + value: boxSettings.isProxyEnabled, + onChanged: (isEnabled) { + _diContainer.boxSettings.isProxyEnabled = isEnabled; + isEnabled + ? _diContainer.networkService.setBootstrapServer( + _diContainer.globals.bsAddressV4, + _diContainer.globals.bsAddressV6) + : _diContainer.networkService + .setBootstrapServer(null, null); + }, + ), + ), + ), + // Shows version + Padding( + padding: paddingV12 + paddingH20, + child: FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: ( + context, + AsyncSnapshot snapshot, + ) => + Text( + 'Version: ${snapshot.data?.version}+${snapshot.data?.buildNumber}')), + ), + ], + ), + ), + ), + ); +} diff --git a/lib/src/home/widgets/my_qr_code_widget.dart b/lib/src/home/widgets/my_qr_code_widget.dart new file mode 100644 index 00000000..a08eb8ba --- /dev/null +++ b/lib/src/home/widgets/my_qr_code_widget.dart @@ -0,0 +1,52 @@ +import '/src/core/theme_data.dart'; +import '/src/core/widgets/misc.dart'; +import '/src/core/widgets/common.dart'; +import '/src/guardian/guardian_controller.dart'; +import '/src/guardian/pages/show_qr_code_page.dart'; + +class MyQRCodeWidget extends StatelessWidget { + const MyQRCodeWidget({super.key}); + + @override + Widget build(BuildContext context) => SelectableCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Become a Guardian', style: textStylePoppins616), + Padding( + padding: paddingTop12, + child: Text( + 'Share your QR code to join a Recovery Group', + style: textStyleSourceSansPro414, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + const Icon(Icons.qr_code_2_rounded, size: 60, color: clWhite), + ], + ), + Padding( + padding: paddingTop20, + child: ElevatedButton( + child: const Text('Generate QR Code'), + onPressed: () => Navigator.of(context).push(MaterialPageRoute( + builder: (context) => ScaffoldWidget( + child: ShowQRCodePage( + qrCode: context + .read() + .getQrCode())))), + ), + ), + ], + ), + ); +} diff --git a/lib/src/intro/intro_controller.dart b/lib/src/intro/intro_controller.dart new file mode 100644 index 00000000..898a096c --- /dev/null +++ b/lib/src/intro/intro_controller.dart @@ -0,0 +1,7 @@ +import '/src/core/controller/page_controller.dart'; + +export 'package:provider/provider.dart'; + +class IntroController extends PageController { + IntroController({required super.diContainer, required super.pagesCount}); +} diff --git a/lib/src/intro/intro_view.dart b/lib/src/intro/intro_view.dart new file mode 100644 index 00000000..b34dc959 --- /dev/null +++ b/lib/src/intro/intro_view.dart @@ -0,0 +1,39 @@ +import '/src/core/di_container.dart'; +import '/src/core/widgets/common.dart'; + +import 'intro_controller.dart'; +import 'pages/intros_page.dart'; +import 'pages/set_device_name_page.dart'; +import 'pages/set_passcode_page.dart'; +import 'pages/set_biometric_page.dart'; + +class IntroView extends StatelessWidget { + static const _pages = [ + IntrosPage(), + SetDeviceNamePage(), + SetPasscodePage(), + SetBiometricPage(), + ]; + + const IntroView({super.key}); + + @override + Widget build(BuildContext context) { + final diContainer = context.read(); + return ChangeNotifierProvider( + create: (context) => IntroController( + diContainer: diContainer, + pagesCount: _pages.length, + ), + child: ScaffoldWidget( + child: Selector( + selector: (_, controller) => controller.currentPage, + builder: (_, currentPage, __) => AnimatedSwitcher( + duration: diContainer.globals.pageChangeDuration, + child: _pages[currentPage], + ), + ), + ), + ); + } +} diff --git a/lib/src/intro/pages/intros_page.dart b/lib/src/intro/pages/intros_page.dart new file mode 100644 index 00000000..c379a380 --- /dev/null +++ b/lib/src/intro/pages/intros_page.dart @@ -0,0 +1,105 @@ +import 'package:flutter_svg/flutter_svg.dart'; + +import '/src/core/theme_data.dart'; +import '/src/core/widgets/misc.dart'; + +import '../intro_controller.dart'; + +class IntrosPage extends StatefulWidget { + const IntrosPage({super.key}); + + @override + State createState() => _IntrosPageState(); +} + +class _IntrosPageState extends State { + static const _titles = [ + 'Welcome to Guardian Keyper', + 'Decentralized', + 'Secure', + 'Never forget again', + ]; + static const _subtitles = [ + 'Guardian Keyper is a secure way to store and recover secrets, such as seed' + ' phrases. With Guardian Keyper, your Web3 assets are safe.', + 'Guardian Keyper splits a secret into a number of encrypted shards. Shards' + ' are then stored on devices owned by “Guardians”, persons you trust.', + 'Each Shard is protected by state-of-the-art encryption algorithms and' + ' can’t be reversed into a seed phrase without approval of Guardians.', + 'You can restore your seed phrase any time with the help of Guardians.' + ' Even in case you’ve lost access to your device.', + ]; + int _step = 0; + + @override + Widget build(BuildContext context) => Padding( + padding: paddingAll20, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragEnd: (details) { + if (details.velocity.pixelsPerSecond.dx < -5) { + if (_step == _titles.length - 1) { + context.read().nextScreen(); + } else if (_step < _titles.length - 1) { + setState(() => _step++); + } + } else if (details.velocity.pixelsPerSecond.dx > 5 && _step > 0) { + setState(() => _step--); + } + }, + child: Column(children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: paddingBottom32, + child: + SvgPicture.asset('assets/images/intro_${_step + 1}.svg'), + ), + Padding( + padding: paddingBottom12, + child: Text( + _titles[_step], + style: textStylePoppins620.copyWith(fontSize: 30), + textAlign: TextAlign.center, + ), + ), + Padding( + padding: paddingBottom32, + child: Text( + _subtitles[_step], + style: textStyleSourceSansPro416, + textAlign: TextAlign.center, + ), + ), + DotBar( + count: 4, + active: _step, + activeColor: clBlue, + passiveColor: clIndigo600, + ), + ], + )), + Padding( + padding: paddingTop20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: context.read().nextScreen, + child: Text('Skip', style: textStylePoppins616), + ), + TextButton( + onPressed: () => _step == (_titles.length - 1) + ? context.read().nextScreen() + : setState(() => _step++), + child: Text('Next', style: textStylePoppins616), + ), + ], + ), + ), + ]), + ), + ); +} diff --git a/lib/src/intro/pages/set_biometric_page.dart b/lib/src/intro/pages/set_biometric_page.dart new file mode 100644 index 00000000..4e4ea481 --- /dev/null +++ b/lib/src/intro/pages/set_biometric_page.dart @@ -0,0 +1,67 @@ +import 'package:flutter_svg/flutter_svg.dart'; + +import '/src/core/di_container.dart'; +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/home/home_view.dart'; + +class SetBiometricPage extends StatelessWidget { + const SetBiometricPage({super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: paddingAll20, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset('assets/images/intro_biometrics.svg'), + Padding( + padding: paddingTop32, + child: Text( + 'Enable biometric authentication?', + style: textStylePoppins620.copyWith(fontSize: 30), + textAlign: TextAlign.center, + ), + ), + Padding( + padding: paddingTop12, + child: Text( + 'Use biometry for faster, easier and secure access to the app.', + style: textStyleSourceSansPro416, + textAlign: TextAlign.center, + ), + ), + Padding( + padding: paddingTop20, + child: Row(children: [ + Expanded( + child: ElevatedButton( + onPressed: () { + context + .read() + .boxSettings + .isBiometricsEnabled = false; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const HomeView())); + }, + child: const Text('No'), + )), + const SizedBox(width: 10), + Expanded( + child: PrimaryButton( + onPressed: () { + context + .read() + .boxSettings + .isBiometricsEnabled = true; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const HomeView())); + }, + text: 'Yes', + )), + ]), + ), + ], + ), + ); +} diff --git a/lib/src/intro/pages/set_device_name_page.dart b/lib/src/intro/pages/set_device_name_page.dart new file mode 100644 index 00000000..9c9fb4e4 --- /dev/null +++ b/lib/src/intro/pages/set_device_name_page.dart @@ -0,0 +1,66 @@ +import '/src/core/di_container.dart'; +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/icon_of.dart'; + +import '../intro_controller.dart'; + +class SetDeviceNamePage extends StatefulWidget { + const SetDeviceNamePage({super.key}); + + @override + State createState() => _SetDeviceNamePageState(); +} + +class _SetDeviceNamePageState extends State { + late final DIContainer diContainer; + String _deviceName = ''; + + @override + void initState() { + super.initState(); + diContainer = context.read(); + _deviceName = diContainer.boxSettings.deviceName; + } + + @override + Widget build(BuildContext context) => ListView( + padding: paddingAll20, + children: [ + const Padding(padding: paddingV20, child: IconOf.app(isBig: true)), + Padding( + padding: paddingV20, + child: Text( + 'Create your Guardian name', + textAlign: TextAlign.center, + style: textStylePoppins620, + ), + ), + Padding( + padding: paddingV20, + child: TextFormField( + autofocus: true, + initialValue: _deviceName, + onChanged: (value) => setState(() => _deviceName = value), + keyboardType: TextInputType.text, + maxLength: diContainer.globals.maxNameLength, + decoration: const InputDecoration( + labelText: ' Guardian name ', + helperText: 'Minimum 3 characters', + ), + )), + Padding( + padding: paddingV20, + child: PrimaryButton( + text: 'Proceed', + onPressed: _deviceName.length < diContainer.globals.minNameLength + ? null + : () { + diContainer.boxSettings.deviceName = _deviceName; + context.read().nextScreen(); + }, + ), + ), + ], + ); +} diff --git a/lib/src/intro/pages/set_passcode_page.dart b/lib/src/intro/pages/set_passcode_page.dart new file mode 100644 index 00000000..1552b9a3 --- /dev/null +++ b/lib/src/intro/pages/set_passcode_page.dart @@ -0,0 +1,90 @@ +import '/src/core/di_container.dart'; +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/auth.dart'; + +import '/src/home/home_view.dart'; + +import '../intro_controller.dart'; + +class SetPasscodePage extends StatefulWidget { + const SetPasscodePage({super.key}); + + @override + State createState() => _SetPasscodePageState(); +} + +class _SetPasscodePageState extends State { + final _passcodeController = InputController(); + @override + void initState() { + super.initState(); + Future.microtask(_createPincode); + } + + @override + void dispose() { + _passcodeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => + Container(color: Theme.of(context).colorScheme.background); + + void _createPincode() { + final diContainer = context.read(); + screenLock( + context: context, + correctString: '', + canCancel: false, + confirmation: true, + digits: diContainer.globals.passCodeLength, + keyPadConfig: keyPadConfig, + secretsConfig: secretsConfig, + screenLockConfig: screenLockConfig, + inputController: _passcodeController, + title: Padding( + padding: paddingV32H20, + child: Text( + 'Create your passcode', + style: textStylePoppins620, + textAlign: TextAlign.center, + )), + confirmTitle: Padding( + padding: paddingV32H20, + child: Text( + 'Enter it once more', + style: textStylePoppins620, + textAlign: TextAlign.center, + )), + customizedButtonChild: Text( + 'Reset', + style: textStyleSourceSansPro416.copyWith(fontSize: 18), + textAlign: TextAlign.center, + ), + customizedButtonTap: _passcodeController.unsetConfirmed, + didConfirmed: (value) { + diContainer.boxSettings.passCode = value; + if (diContainer.platformService.hasBiometrics) { + context.read().nextScreen(); + Navigator.of(context).pop(); + } else { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const HomeView()), + (route) => false, + ); + } + }, + didError: (_) async { + ScaffoldMessenger.of(context).showSnackBar(buildSnackBar( + text: 'Wrong passcode!', + duration: const Duration(seconds: 2), + isFloating: true, + isError: true, + )); + await diContainer.platformService.vibrate(); + }, + ); + } +} diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb new file mode 100644 index 00000000..537a25d3 --- /dev/null +++ b/lib/src/localization/app_en.arb @@ -0,0 +1,6 @@ +{ + "appTitle": "Guardian Keyper", + "@appTitle": { + "description": "The title of the application" + } +} diff --git a/lib/src/recovery_group/add_guardian/add_guardian_controller.dart b/lib/src/recovery_group/add_guardian/add_guardian_controller.dart new file mode 100644 index 00000000..e227fd57 --- /dev/null +++ b/lib/src/recovery_group/add_guardian/add_guardian_controller.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import '/src/core/model/core_model.dart'; + +import '../recovery_group_controller.dart'; + +export 'package:provider/provider.dart'; + +class AddGuardianController extends RecoveryGroupController { + final GroupId groupId; + String _tag = ''; + QRCode? _qrCode; + Timer? _timer; + StreamSubscription? _subscription; + + AddGuardianController({ + required super.diContainer, + required super.pagesCount, + required this.groupId, + }); + + String get guardianTag => _tag; + + QRCode? get qrCode => _qrCode; + + RecoveryGroupModel? get group => getGroupById(groupId); + + bool get isDuplicate => group?.guardians.containsKey(qrCode?.peerId) ?? false; + + bool get isWaiting => _subscription != null && !_subscription!.isPaused; + + set guardianTag(String value) { + if (value == _tag) return; + _tag = value; + notifyListeners(); + } + + @override + void dispose() { + _subscription?.cancel(); + _timer?.cancel(); + super.dispose(); + } + + void startRequest({ + Callback? onRejected, + Callback? onFailed, + }) { + _subscription = networkStream.listen((MessageModel message) { + if (!isWaiting) return; + if (!message.hasResponse) return; + if (message.type != OperationType.authPeer) return; + if (_qrCode == null || message.peerId != _qrCode!.peerId) return; + _timer?.cancel(); + _subscription?.pause(); + if (message.isAccepted) return nextScreen(); + if (message.isRejected) return onRejected?.call(message); + if (message.isFailed) return onFailed?.call(message); + }); + _timer = Timer.periodic(globals.retryNetworkTimeout, sendAuthRequest); + sendAuthRequest(); + } + + void setQRCode(QRCode qrCode) { + if (_qrCode != null) return; + if (qrCode.type != OperationType.authPeer) return; + if (qrCode == _qrCode) return; + _qrCode = qrCode; + nextScreen(); + } + + Future addGuardianToGroup() => addGuardian( + groupId, + GuardianModel( + peerId: _qrCode!.peerId, + name: _qrCode!.peerName, + tag: _tag, + )); + + Future sendAuthRequest([Timer? timer]) { + for (var e in qrCode!.addresses) { + addPeer(qrCode!.peerId, e.rawAddress); + } + return sendToGuardian(MessageModel( + peerId: qrCode!.peerId, + type: OperationType.authPeer, + nonce: qrCode!.nonce, + secretShard: SecretShardModel( + ownerName: myDeviceName, + groupId: group!.id, + groupName: group!.name, + ), + )); + } +} diff --git a/lib/src/recovery_group/add_guardian/add_guardian_view.dart b/lib/src/recovery_group/add_guardian/add_guardian_view.dart new file mode 100644 index 00000000..f62d1303 --- /dev/null +++ b/lib/src/recovery_group/add_guardian/add_guardian_view.dart @@ -0,0 +1,43 @@ +import '/src/core/di_container.dart'; +import '/src/core/model/core_model.dart'; +import '/src/core/widgets/common.dart'; + +import 'add_guardian_controller.dart'; +import 'pages/scan_qrcode_page.dart'; +import 'pages/loading_page.dart'; +import 'pages/add_tag_page.dart'; + +class AddGuardianView extends StatelessWidget { + static const routeName = '/recovery_group/add_guardian'; + static const _pages = [ + ScanQRCodePage(), + LoadingPage(), + AddTagPage(), + ]; + + final GroupId groupId; + + const AddGuardianView({super.key, required this.groupId}); + + @override + Widget build(BuildContext context) { + final diContainer = context.read(); + return ChangeNotifierProvider( + create: (_) => AddGuardianController( + diContainer: diContainer, + pagesCount: _pages.length, + groupId: groupId, + ), + lazy: false, + child: ScaffoldWidget( + child: Selector( + selector: (_, controller) => controller.currentPage, + builder: (_, currentPage, __) => AnimatedSwitcher( + duration: diContainer.globals.pageChangeDuration, + child: _pages[currentPage], + ), + ), + ), + ); + } +} diff --git a/lib/src/recovery_group/add_guardian/pages/add_tag_page.dart b/lib/src/recovery_group/add_guardian/pages/add_tag_page.dart new file mode 100644 index 00000000..8fee00d5 --- /dev/null +++ b/lib/src/recovery_group/add_guardian/pages/add_tag_page.dart @@ -0,0 +1,83 @@ +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/model/core_model.dart'; + +import '../add_guardian_controller.dart'; +import '../../widgets/guardian_tile_widget.dart'; + +class AddTagPage extends StatelessWidget { + const AddTagPage({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Provider.of(context); + return Column( + children: [ + // Header + const HeaderBar( + caption: 'Add Guardians', + closeButton: HeaderBarCloseButton(), + ), + // Body + Expanded( + child: ListView( + primary: true, + shrinkWrap: true, + padding: paddingH20, + children: [ + Padding( + padding: paddingTop32, + child: Text( + 'Guardian identified', + textAlign: TextAlign.center, + style: textStylePoppins620, + ), + ), + Padding( + padding: paddingTop12, + child: Text( + 'We advise you to add a tag name to remember ' + 'the owner of this device in case of a recovery.', + textAlign: TextAlign.center, + style: textStyleSourceSansPro414Purple, + ), + ), + Padding( + padding: paddingTop32, + child: GuardianTileWidget( + isSuccess: true, + guardian: GuardianModel( + name: controller.qrCode!.peerName, + tag: controller.guardianTag, + peerId: controller.qrCode!.peerId, + ), + ), + ), + Padding( + padding: paddingTop32, + child: TextFormField( + keyboardType: TextInputType.name, + maxLength: controller.diContainer.globals.maxNameLength, + onChanged: (value) => controller.guardianTag = value, + decoration: const InputDecoration( + labelText: ' Tag name ', + helperText: 'You can leave it blank', + ), + ), + ), + // Footer + Padding( + padding: paddingTop32, + child: PrimaryButton( + text: 'Continue', + onPressed: () async => Navigator.of(context) + .pop(await controller.addGuardianToGroup()), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/recovery_group/add_guardian/pages/loading_page.dart b/lib/src/recovery_group/add_guardian/pages/loading_page.dart new file mode 100644 index 00000000..12bddd00 --- /dev/null +++ b/lib/src/recovery_group/add_guardian/pages/loading_page.dart @@ -0,0 +1,132 @@ +import 'dart:async'; +import 'package:wakelock/wakelock.dart'; + +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/icon_of.dart'; + +import '../add_guardian_controller.dart'; + +class LoadingPage extends StatefulWidget { + const LoadingPage({super.key}); + + @override + State createState() => _LoadingPageState(); +} + +class _LoadingPageState extends State { + @override + void initState() { + super.initState(); + Wakelock.enable(); + final controller = context.read(); + if (controller.qrCode == null || + controller.qrCode!.createdAt + .add(controller.globals.qrCodeExpires) + .isAfter(DateTime.now())) { + Future.microtask(_showErrorFailed); + return; + } + if (controller.isDuplicate) { + Future.microtask(_showErrorDuplicate); + return; + } + controller.startRequest( + onRejected: _showRejected, + onFailed: _showErrorFailed, + ); + } + + @override + void dispose() { + Wakelock.disable(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = Provider.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + const HeaderBar( + caption: 'Add Guardians', + closeButton: HeaderBarCloseButton(), + ), + // Body + const Padding(padding: paddingTop32), + Padding( + padding: paddingH20, + child: Card( + child: Column( + children: [ + Padding( + padding: paddingTop20, + child: Visibility( + visible: controller.isWaiting, + child: const CircularProgressIndicator.adaptive(), + ), + ), + Padding( + padding: paddingAll20, + child: Text( + 'Awaiting ${controller.qrCode?.peerName}’s response', + style: textStyleSourceSansPro416, + ), + ), + ], + )), + ), + ], + ); + } + + void _showRejected([_]) => showModalBottomSheet( + context: context, + isDismissible: false, + isScrollControlled: true, + builder: (BuildContext context) => BottomSheetWidget( + titleString: 'Guardian rejected', + textString: 'Guardian has rejected the invitation to your ' + 'Recovery Group. You can try again or add another Guardian.', + icon: const IconOf.shield(isBig: true, bage: BageType.error), + footer: PrimaryButton( + text: 'Done', + onPressed: Navigator.of(context).pop, + ), + ), + ).then(Navigator.of(context).pop); + + void _showErrorDuplicate() => showModalBottomSheet( + context: context, + isDismissible: false, + isScrollControlled: true, + builder: (BuildContext context) => BottomSheetWidget( + titleString: 'You can’t add the same Guardian twice', + textString: 'Seems like this Guardian you’ve already added.' + ' Try adding a different Guardian. ', + icon: const IconOf.shield(isBig: true, bage: BageType.error), + footer: PrimaryButton( + text: 'Done', + onPressed: Navigator.of(context).pop, + ), + ), + ).then(Navigator.of(context).pop); + + void _showErrorFailed([_]) => showModalBottomSheet( + context: context, + isDismissible: false, + isScrollControlled: true, + builder: (BuildContext context) => BottomSheetWidget( + titleString: 'Invalid QR Code', + textString: + 'Please ask the Guardian to generate a new QR Code via dashboard. ', + icon: const IconOf.shield(isBig: true, bage: BageType.error), + footer: PrimaryButton( + text: 'Done', + onPressed: Navigator.of(context).pop, + ), + ), + ).then(Navigator.of(context).pop); +} diff --git a/lib/src/recovery_group/add_guardian/pages/scan_qrcode_page.dart b/lib/src/recovery_group/add_guardian/pages/scan_qrcode_page.dart new file mode 100644 index 00000000..545468c8 --- /dev/null +++ b/lib/src/recovery_group/add_guardian/pages/scan_qrcode_page.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +import '../add_guardian_controller.dart'; +import '../../widgets/get_qrcode_widget.dart'; + +class ScanQRCodePage extends StatelessWidget { + const ScanQRCodePage({super.key}); + + @override + Widget build(BuildContext context) => GetQRCodeWidget( + resultCallback: context.read().setQRCode, + ); +} diff --git a/lib/src/recovery_group/add_secret/add_secret_controller.dart b/lib/src/recovery_group/add_secret/add_secret_controller.dart new file mode 100644 index 00000000..87d7f2c6 --- /dev/null +++ b/lib/src/recovery_group/add_secret/add_secret_controller.dart @@ -0,0 +1,127 @@ +import 'dart:async'; +import 'package:sss256/sss256.dart'; + +import '/src/core/model/core_model.dart'; + +import '../recovery_group_controller.dart'; + +export 'package:provider/provider.dart'; + +class AddSecretController extends RecoveryGroupController { + final Map _shards = {}; + final Map _responses = {}; + final Map _subscriptions = {}; + final Map _statuses = {}; + final GroupId groupId; + String secret = ''; + Timer? _timer; + StreamSubscription? _subscription; + + AddSecretController({ + required super.diContainer, + required super.pagesCount, + required this.groupId, + }); + + Map get statuses => _statuses; + + Map get responses => _responses; + + RecoveryGroupModel get group => getGroupById(groupId)!; + + @override + void dispose() { + for (var s in _subscriptions.values) { + s.cancel(); + } + _subscription?.cancel(); + _timer?.cancel(); + super.dispose(); + } + + void startRequest({ + Callback? onSuccess, + Callback? onReject, + Callback? onFailed, + }) { + _subscription = networkStream.listen((MessageModel message) { + if (message.type != OperationType.setShard) return; + if (!message.hasResponse) return; + _responses[message.peerId] = message.status; + notifyListeners(); + if (message.isAccepted) { + removeShard(message.peerId); + if (_shards.isEmpty) onSuccess?.call(message); + return; + } + if (message.isRejected) { + _timer?.cancel(); + _subscription?.cancel(); + _shards.clear(); + onReject?.call(message); + return; + } + onFailed?.call(message); + }); + for (var peerId in group.guardians.keys) { + _subscriptions[peerId] = diContainer.networkService.onPeerStatusChanged( + (bool isOnline) { + if (isOnline == _statuses[peerId]) return; + _statuses[peerId] = isOnline; + notifyListeners(); + }, + peerId, + ); + } + _splitSecret(); + _timer = Timer.periodic(globals.retryNetworkTimeout, distributeShards); + distributeShards(); + } + + void _splitSecret() { + final shards = splitSecret( + treshold: group.threshold, + shares: group.maxSize, + secret: secret, + ); + if (secret != restoreSecret(shares: shards.sublist(0, group.threshold))) { + throw Exception('Can not restore the secret'); + } + final shardsIterator = shards.iterator; + for (var guardian in group.guardians.values) { + if (shardsIterator.moveNext()) { + _shards[guardian.peerId] = SecretShardModel( + value: shardsIterator.current, + ownerId: PeerId.empty(), + ownerName: myDeviceName, + groupId: group.id, + groupName: group.name, + groupSize: group.currentSize, + groupThreshold: group.threshold, + ); + } + } + } + + Future distributeShards([Timer? timer]) async { + if (_shards.isEmpty) return timer?.cancel(); + await Future.wait([ + for (var shard in _shards.entries) + sendToGuardian(MessageModel( + peerId: shard.key, + type: OperationType.setShard, + secretShard: shard.value, + )) + ]); + } + + void removeShard(PeerId peerId) async { + _shards.remove(peerId); + if (_shards.isEmpty) { + final updatedGroup = group.completeGroup(); + await diContainer.boxRecoveryGroup + .put(updatedGroup.id.asKey, updatedGroup); + } + notifyListeners(); + } +} diff --git a/lib/src/recovery_group/add_secret/add_secret_view.dart b/lib/src/recovery_group/add_secret/add_secret_view.dart new file mode 100644 index 00000000..b45ec9fe --- /dev/null +++ b/lib/src/recovery_group/add_secret/add_secret_view.dart @@ -0,0 +1,42 @@ +import '/src/core/di_container.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/model/core_model.dart'; + +import 'add_secret_controller.dart'; +import 'pages/add_secret_page.dart'; +import 'pages/split_and_share_page.dart'; +import 'pages/secret_transmitting_page.dart'; + +class RecoveryGroupAddSecretView extends StatelessWidget { + static const routeName = '/recovery_group/add_secret'; + static const _pages = [ + AddSecretPage(), + SplitAndShareSecretPage(), + SecretTransmittingPage(), + ]; + + final GroupId groupId; + + const RecoveryGroupAddSecretView({super.key, required this.groupId}); + + @override + Widget build(BuildContext context) { + final diContainer = context.read(); + return ChangeNotifierProvider( + create: (context) => AddSecretController( + diContainer: diContainer, + pagesCount: _pages.length, + groupId: groupId, + ), + child: ScaffoldWidget( + child: Selector( + selector: (_, controller) => controller.currentPage, + builder: (_, currentPage, __) => AnimatedSwitcher( + duration: diContainer.globals.pageChangeDuration, + child: _pages[currentPage], + ), + ), + ), + ); + } +} diff --git a/lib/src/recovery_group/add_secret/pages/add_secret_page.dart b/lib/src/recovery_group/add_secret/pages/add_secret_page.dart new file mode 100644 index 00000000..e492aca8 --- /dev/null +++ b/lib/src/recovery_group/add_secret/pages/add_secret_page.dart @@ -0,0 +1,181 @@ +import 'dart:async'; +import 'package:app_settings/app_settings.dart'; +import 'package:airplane_mode_checker/airplane_mode_checker.dart'; + +import '/src/core/theme_data.dart'; +import '/src/core/widgets/misc.dart'; +import '/src/core/widgets/common.dart'; + +import '../add_secret_controller.dart'; +import '../widgets/add_secret_close_button.dart'; + +class AddSecretPage extends StatefulWidget { + const AddSecretPage({super.key}); + + @override + State createState() => _AddSecretPageState(); +} + +class _AddSecretPageState extends State { + final _ctrl = TextEditingController(); + bool _isSecretObscure = true; + AirplaneModeStatus _airplaneModeStatus = AirplaneModeStatus.on; + Timer? _timer; + + @override + void initState() { + super.initState(); + Future.microtask(_showExplainer); + _timer = Timer.periodic(const Duration(seconds: 1), (_) async { + _airplaneModeStatus = await AirplaneModeChecker.checkAirplaneMode(); + setState(() {}); + }); + } + + @override + void dispose() { + _ctrl.dispose(); + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = Provider.of(context); + return Column( + children: [ + // Header + const HeaderBar( + caption: 'Add secret', + closeButton: AddSecretCloseButton(), + ), + // Body + Expanded( + child: ListView( + primary: true, + shrinkWrap: true, + padding: paddingH20, + children: [ + PageTitle( + title: 'Add your Secret for ${controller.group.name}', + subtitle: 'Make sure no one can see your screen.', + ), + // Open Settings Tile + if (_airplaneModeStatus == AirplaneModeStatus.off) + Padding( + padding: paddingBottom32, + child: ListTile( + leading: const Icon( + Icons.error_outline, + color: clYellow, + size: 40, + ), + title: Text( + 'Turn on Airplane Mode', + style: textStyleSourceSansPro614, + ), + subtitle: Text( + 'Make current step more secure', + style: textStyleSourceSansPro414Purple, + ), + trailing: const Icon(Icons.arrow_forward_ios), + onTap: AppSettings.openWirelessSettings, + ), + ), + // Input + TextFormField( + controller: _ctrl, + enableInteractiveSelection: true, + toolbarOptions: const ToolbarOptions( + cut: true, + paste: true, + selectAll: true, + ), + keyboardType: TextInputType.multiline, + obscureText: _isSecretObscure, + maxLines: _isSecretObscure ? 1 : null, + maxLength: controller.diContainer.globals.maxSecretLength, + style: textStyleSourceSansPro416, + decoration: InputDecoration( + floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: 'YOUR SECRET', + counterStyle: textStyleSourceSansPro414Purple, + suffix: _isSecretObscure + ? Container( + decoration: BoxDecoration( + border: Border.all(color: clBlue), + shape: BoxShape.circle, + ), + height: 40, + child: IconButton( + color: clWhite, + icon: const Icon(Icons.visibility_outlined), + onPressed: () => setState( + () => _isSecretObscure = !_isSecretObscure), + ), + ) + : Container( + decoration: const BoxDecoration( + color: clBlue, + shape: BoxShape.circle, + ), + height: 40, + child: IconButton( + color: clWhite, + icon: const Icon(Icons.visibility_off_outlined), + onPressed: () => setState( + () => _isSecretObscure = !_isSecretObscure), + ), + ), + ), + ), + // Footer + Padding( + padding: paddingV32, + child: PrimaryButton( + text: 'Continue', + onPressed: _ctrl.text.isEmpty + ? null + : () { + controller.secret = _ctrl.text; + controller.nextScreen(); + }), + ), + ], + ), + ), + ], + ); + } + + void _showExplainer() => showModalBottomSheet( + context: context, + isDismissible: true, + isScrollControlled: true, + builder: (context) => BottomSheetWidget( + icon: CircleAvatar( + radius: 40, + backgroundColor: Theme.of(context).colorScheme.secondary, + child: const Icon( + Icons.warning_rounded, + size: 40, + color: clYellow, + ), + ), + titleString: 'Before you proceed', + body: Container( + decoration: boxDecoration, + margin: paddingV12, + padding: paddingAll20, + child: const NumberedListWidget(list: [ + 'Switch your phone to Аirplane mode', + 'Make sure no one can see your screen', + 'Copy and paste your secret to avoid typing', + ]), + ), + footer: PrimaryButton( + text: 'Got it', + onPressed: Navigator.of(context).pop, + ), + )); +} diff --git a/lib/src/recovery_group/add_secret/pages/secret_transmitting_page.dart b/lib/src/recovery_group/add_secret/pages/secret_transmitting_page.dart new file mode 100644 index 00000000..fbaeca7c --- /dev/null +++ b/lib/src/recovery_group/add_secret/pages/secret_transmitting_page.dart @@ -0,0 +1,144 @@ +import 'package:wakelock/wakelock.dart'; + +import '/src/core/di_container.dart'; +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/icon_of.dart'; +import '/src/core/model/core_model.dart'; + +import '../add_secret_controller.dart'; +import '../widgets/add_secret_close_button.dart'; +import '../../widgets/guardian_tile_widget.dart'; + +class SecretTransmittingPage extends StatefulWidget { + const SecretTransmittingPage({super.key}); + + @override + State createState() => _SecretTransmittingPageState(); +} + +class _SecretTransmittingPageState extends State { + @override + void initState() { + super.initState(); + Wakelock.enable(); + context.read().startRequest( + onSuccess: _showSuccess, + onReject: _showRejected, + ); + } + + @override + void dispose() { + Wakelock.disable(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = Provider.of(context); + return Column( + children: [ + // Header + const HeaderBar( + caption: 'Split Secret', + closeButton: AddSecretCloseButton(), + ), + // Body + Expanded( + child: ListView( + padding: paddingH20, + children: [ + const PageTitle( + title: 'Waiting for Guardians', + subtitleSpans: [ + TextSpan( + text: 'Ask each Guardian to log into the app ' + 'to receive a Secret Shard. Once Shard is received ' + 'Guardian icon will go '), + TextSpan( + text: 'green.', + style: TextStyle(color: clGreen), + ), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var guardian in controller.group.guardians.values) + Padding( + padding: paddingV6, + child: GuardianTileWidget( + guardian: guardian, + isSuccess: controller.responses[guardian.peerId] == null + ? null + : controller.responses[guardian.peerId] == + MessageStatus.accepted, + isWaiting: + !controller.responses.containsKey(guardian.peerId), + isOnline: controller.statuses[guardian.peerId] ?? false, + ), + ) + ], + ), + ], + ), + ), + ], + ); + } + + void _showSuccess(MessageModel message) => showModalBottomSheet( + context: context, + isDismissible: true, + isScrollControlled: true, + builder: (context) => BottomSheetWidget( + icon: const IconOf.splitAndShare(isBig: true, bage: BageType.ok), + titleString: 'Your Secret has been split', + textSpan: [ + const TextSpan(text: 'Now you can restore your '), + TextSpan( + text: message.secretShard.groupName, + style: textStyleBold, + ), + const TextSpan(text: ' Secret with the help of Guardians.'), + ], + footer: Padding( + padding: paddingV20, + child: PrimaryButton( + text: 'Done', + onPressed: Navigator.of(context).pop, + ), + ), + ), + ).then(Navigator.of(context).pop); + + void _showRejected(MessageModel message) => showModalBottomSheet( + context: context, + isDismissible: false, + isScrollControlled: true, + builder: (context) => BottomSheetWidget( + icon: const IconOf.splitAndShare(isBig: true, bage: BageType.error), + titleString: + 'Guardian rejected your Secret. The group will be removed.', + textSpan: [ + const TextSpan(text: 'Sharding process for '), + TextSpan(text: message.secretShard.groupName, style: textStyleBold), + const TextSpan( + text: ' has been terminated by one of you Guardians.'), + ], + footer: Padding( + padding: paddingV20, + child: PrimaryButton( + text: 'Done', + onPressed: () => context + .read() + .boxRecoveryGroup + .delete(message.secretShard.groupId.asKey) + .then( + (_) => Navigator.of(context).popUntil((r) => r.isFirst)), + ), + ), + ), + ); +} diff --git a/lib/src/recovery_group/add_secret/pages/split_and_share_page.dart b/lib/src/recovery_group/add_secret/pages/split_and_share_page.dart new file mode 100644 index 00000000..78bca9af --- /dev/null +++ b/lib/src/recovery_group/add_secret/pages/split_and_share_page.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'package:app_settings/app_settings.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; + +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; + +import '../add_secret_controller.dart'; +import '../widgets/add_secret_close_button.dart'; +import '../../widgets/guardian_tile_widget.dart'; + +class SplitAndShareSecretPage extends StatefulWidget { + const SplitAndShareSecretPage({super.key}); + + @override + State createState() => + _SplitAndShareSecretPageState(); +} + +class _SplitAndShareSecretPageState extends State { + late final StreamSubscription _subscription; + bool _haveConnection = true; + + @override + void initState() { + super.initState(); + Connectivity().checkConnectivity().then((result) => setState(() => + _haveConnection = result == ConnectivityResult.wifi || + result == ConnectivityResult.mobile)); + _subscription = Connectivity().onConnectivityChanged.listen( + (ConnectivityResult result) => setState(() => _haveConnection = + result == ConnectivityResult.wifi || + result == ConnectivityResult.mobile)); + } + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = context.read(); + return Column( + children: [ + // Header + const HeaderBar( + caption: 'Split Secret', + closeButton: AddSecretCloseButton(), + ), + // Body + Expanded( + child: ListView( + padding: paddingH20, + primary: true, + shrinkWrap: true, + children: [ + PageTitle( + title: 'Split and share Secret with Guardians below', + subtitleSpans: [ + const TextSpan( + text: 'You are about to split your Secret in '), + TextSpan( + text: '${controller.group.maxSize} encrypted Shards.'), + const TextSpan( + text: ' Each Guardian will receieve their own' + ' Shard which can be used to restore your Secret.'), + ], + ), + Column(children: [ + for (var guardian in controller.group.guardians.values) + Padding( + padding: paddingV6, + child: GuardianTileWidget(guardian: guardian), + ) + ]), + // Open Settings Tile + if (!_haveConnection) + Padding( + padding: paddingTop20, + child: ListTile( + leading: const Icon( + Icons.error_outline, + color: clYellow, + size: 40, + ), + title: Text( + 'No connection', + style: textStyleSourceSansPro614, + ), + subtitle: Text( + 'Connect to the Internet to continue', + style: textStyleSourceSansPro414Purple, + ), + trailing: const Icon(Icons.arrow_forward_ios), + onTap: AppSettings.openWirelessSettings, + ), + ), + // Footer + Padding( + padding: paddingV32, + child: PrimaryButton( + text: 'Continue', + onPressed: controller.nextScreen, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/recovery_group/add_secret/widgets/add_secret_close_button.dart b/lib/src/recovery_group/add_secret/widgets/add_secret_close_button.dart new file mode 100644 index 00000000..ff320b67 --- /dev/null +++ b/lib/src/recovery_group/add_secret/widgets/add_secret_close_button.dart @@ -0,0 +1,48 @@ +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/icon_of.dart'; + +class AddSecretCloseButton extends StatelessWidget { + const AddSecretCloseButton({super.key}); + + @override + Widget build(BuildContext context) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => BottomSheetWidget( + icon: const IconOf.splitAndShare(isBig: true, bage: BageType.error), + titleString: 'Cancel adding a Secret?', + textString: 'All progress will be lost, you’ll have to start ' + 'from the beginning. Are you sure?', + footer: Padding( + padding: paddingV20, + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: Navigator.of(context).pop, + child: const Text('No'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: PrimaryButton( + text: 'Yes', + onPressed: () => Navigator.of(context).pop(true), + ), + ), + ], + ), + ), + ), + ).then((result) => result == true ? Navigator.of(context).pop() : null), + child: Center( + child: CircleAvatar( + backgroundColor: Theme.of(context).listTileTheme.tileColor, + child: const Icon(Icons.close, color: clWhite), + ), + ), + ); +} diff --git a/lib/src/recovery_group/create_group/create_group_controller.dart b/lib/src/recovery_group/create_group/create_group_controller.dart new file mode 100644 index 00000000..1677b3b1 --- /dev/null +++ b/lib/src/recovery_group/create_group/create_group_controller.dart @@ -0,0 +1,35 @@ +import '/src/core/model/core_model.dart'; + +import '../recovery_group_controller.dart'; + +export 'package:provider/provider.dart'; + +class CreateGroupController extends RecoveryGroupController { + RecoveryGroupType? _groupType; + int _groupSize = 3; + String _groupName = ''; + + CreateGroupController({ + required super.diContainer, + required super.pagesCount, + }); + + RecoveryGroupType? get groupType => _groupType; + int get groupSize => _groupSize; + String get groupName => _groupName; + + set groupType(RecoveryGroupType? value) { + _groupType = value; + notifyListeners(); + } + + set groupSize(int size) { + _groupSize = size; + notifyListeners(); + } + + set groupName(String value) { + _groupName = value; + notifyListeners(); + } +} diff --git a/lib/src/recovery_group/create_group/create_group_view.dart b/lib/src/recovery_group/create_group/create_group_view.dart new file mode 100644 index 00000000..292e15f6 --- /dev/null +++ b/lib/src/recovery_group/create_group/create_group_view.dart @@ -0,0 +1,39 @@ +import '/src/core/di_container.dart'; +import '/src/core/widgets/common.dart'; + +import 'create_group_controller.dart'; +import 'pages/choose_size_page.dart'; +import 'pages/choose_type_page.dart'; +import 'pages/input_name_page.dart'; + +class CreateGroupView extends StatelessWidget { + static const routeName = '/recovery_group_create'; + + static const _pages = [ + ChooseTypePage(), + ChooseSizePage(), + InputNamePage(), + ]; + + const CreateGroupView({super.key}); + + @override + Widget build(BuildContext context) { + final diContainer = context.read(); + return ChangeNotifierProvider( + create: (_) => CreateGroupController( + diContainer: diContainer, + pagesCount: _pages.length, + ), + child: ScaffoldWidget( + child: Selector( + selector: (_, controller) => controller.currentPage, + builder: (context, currentPage, __) => AnimatedSwitcher( + duration: diContainer.globals.pageChangeDuration, + child: _pages[currentPage], + ), + ), + ), + ); + } +} diff --git a/lib/src/recovery_group/create_group/pages/choose_size_page.dart b/lib/src/recovery_group/create_group/pages/choose_size_page.dart new file mode 100644 index 00000000..d3dfce0b --- /dev/null +++ b/lib/src/recovery_group/create_group/pages/choose_size_page.dart @@ -0,0 +1,172 @@ +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; + +import '../create_group_controller.dart'; + +class ChooseSizePage extends StatefulWidget { + const ChooseSizePage({super.key}); + + @override + State createState() => _ChooseSizePageState(); +} + +class _ChooseSizePageState extends State + with SingleTickerProviderStateMixin { + static final _boxDecorationSelected = BoxDecoration( + border: Border.all(color: clIndigo500), + borderRadius: borderRadius, + gradient: const RadialGradient( + center: Alignment.bottomCenter, + radius: 1.4, + colors: [ + Color(0xFF7E4CDE), + Color(0xFF35088B), + ], + ), + ); + + late final AnimationController _animationController = + AnimationController(vsync: this, duration: const Duration(seconds: 1)) + ..forward(); + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = Provider.of(context); + return Column( + children: [ + // Header + HeaderBar( + caption: 'Group Size', + backButton: HeaderBarBackButton(onPressed: controller.previousScreen), + closeButton: const HeaderBarCloseButton(), + ), + // Body + Expanded( + child: ListView( + padding: paddingAll20, + children: [ + Padding( + padding: paddingBottom20, + child: Text( + 'Choose how many Guardians will keep parts of your Secret', + style: textStylePoppins620, + textAlign: TextAlign.center, + ), + ), + Padding( + padding: paddingBottom32, + child: Text( + 'You can join parts to recover your Secret at any time.', + style: textStyleSourceSansPro416, + textAlign: TextAlign.center, + ), + ), + Container( + decoration: BoxDecoration( + border: Border.all(color: clIndigo500), + borderRadius: borderRadius, + color: clIndigo800, + ), + padding: paddingAll8, + child: Row( + children: [ + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + controller.groupSize = 3; + _animationController.forward(from: 0); + }, + child: Container( + decoration: controller.groupSize == 3 + ? _boxDecorationSelected + : null, + padding: paddingAll20, + child: Column( + children: [ + const Text('3 Guardians'), + Radio( + value: 3, + groupValue: controller.groupSize, + onChanged: (value) { + if (value != null) { + controller.groupSize = value; + } + _animationController.forward(from: 0); + }, + ), + ], + ), + ), + ), + ), + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + controller.groupSize = 5; + _animationController.forward(from: 0); + }, + child: Container( + decoration: controller.groupSize == 5 + ? _boxDecorationSelected + : null, + padding: paddingAll20, + child: Column( + children: [ + const Text('5 Guardians'), + Radio( + value: 5, + groupValue: controller.groupSize, + onChanged: (value) { + if (value != null) { + controller.groupSize = value; + } + _animationController.forward(from: 0); + }, + ), + ], + ), + ), + ), + ), + ], + ), + ), + Padding( + padding: paddingTop32, + child: InfoPanel.info( + animationController: _animationController, + textSpan: [ + const TextSpan(text: 'You will need atleast '), + TextSpan( + text: controller.groupSize == 3 + ? '2 out of 3' + : '3 out of 5', + style: textStyleSourceSansPro616.copyWith(color: clWhite), + ), + const TextSpan( + text: ' Guardians to approve Secret recovery.'), + ], + ), + ), + // Footer + Padding( + padding: paddingV32, + child: PrimaryButton( + text: 'Continue', + onPressed: controller.nextScreen, + )), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/recovery_group/create_group/pages/choose_type_page.dart b/lib/src/recovery_group/create_group/pages/choose_type_page.dart new file mode 100644 index 00000000..671eb35b --- /dev/null +++ b/lib/src/recovery_group/create_group/pages/choose_type_page.dart @@ -0,0 +1,104 @@ +import '/src/core/theme_data.dart'; +import '/src/core/widgets/misc.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/icon_of.dart'; +import '/src/core/model/core_model.dart'; + +import '../create_group_controller.dart'; + +class ChooseTypePage extends StatelessWidget { + const ChooseTypePage({super.key}); + + @override + Widget build(BuildContext context) => Column( + children: [ + // Header + const HeaderBar( + caption: 'Create a Recovery Group', + closeButton: HeaderBarCloseButton(), + ), + // Body + Expanded( + child: ListView( + padding: paddingAll20, + children: [ + // Your Devices + GestureDetector( + onTap: () { + final controller = context.read(); + controller.groupType = RecoveryGroupType.devices; + controller.nextScreen(); + }, + child: SelectableCard( + isSelected: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const IconOf.yourDevices(bgColor: clWhite), + Padding( + padding: paddingTop20, + child: Text( + 'Your Devices', + style: textStylePoppins616, + ), + ), + Padding( + padding: paddingTop12, + child: Text( + _textYourDevices, + style: textStyleSourceSansPro414Purple, + ), + ), + ], + )), + ), + const Padding(padding: paddingTop20), + // Fiduciaries + SelectableCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const IconOf.fiduciaries(bgColor: clWhite), + Container( + height: 28, + width: 53, + alignment: Alignment.center, + decoration: + boxDecoration.copyWith(color: clIndigo500), + child: Text('Soon', style: textStyleSourceSansPro614), + ), + ], + ), + Padding( + padding: paddingTop20, + child: Text('Fiduciaries', style: textStylePoppins616), + ), + Padding( + padding: paddingTop12, + child: Text( + _textFiduciaries, + style: textStyleSourceSansPro414Purple, + ), + ), + ], + )), + ], + ), + ), + ], + ); +} + +const _textYourDevices = ''' +Your devices and devices that belong to your +Guardians, trusted people, friends and family +who act on your behalf when required. +'''; + +const _textFiduciaries = ''' +Trusted appointed fiduciary third parties +appointed to act as Guardians on your +behalf on a professional basis.'''; diff --git a/lib/src/recovery_group/create_group/pages/input_name_page.dart b/lib/src/recovery_group/create_group/pages/input_name_page.dart new file mode 100644 index 00000000..34e0a5e5 --- /dev/null +++ b/lib/src/recovery_group/create_group/pages/input_name_page.dart @@ -0,0 +1,61 @@ +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/model/core_model.dart'; + +import '../create_group_controller.dart'; +import '../../edit_group/recovery_group_edit_view.dart'; + +class InputNamePage extends StatelessWidget { + const InputNamePage({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Provider.of(context); + return Column( + children: [ + // Header + HeaderBar( + caption: 'Add Name', + backButton: HeaderBarBackButton(onPressed: controller.previousScreen), + closeButton: const HeaderBarCloseButton(), + ), + // Body + Expanded( + child: ListView( + padding: paddingH20, + children: [ + const PageTitle(title: 'Create a name for your Recovery Group'), + TextFormField( + autofocus: true, + keyboardType: TextInputType.text, + maxLength: controller.diContainer.globals.maxNameLength, + decoration: const InputDecoration(labelText: ' Group name '), + onChanged: (value) => controller.groupName = value, + ), + // Footer + Padding( + padding: paddingV32, + child: PrimaryButton( + text: 'Continue', + onPressed: controller.groupName.isEmpty + ? null + : () async => Navigator.popAndPushNamed( + context, + RecoveryGroupEditView.routeName, + arguments: + (await controller.addGroup(RecoveryGroupModel( + id: GroupId.aNew(), + name: controller.groupName, + type: controller.groupType!, + maxSize: controller.groupSize, + ))) + .id, + ), + ), + ), + ], + )), + ], + ); + } +} diff --git a/lib/src/recovery_group/edit_group/recovery_group_edit_view.dart b/lib/src/recovery_group/edit_group/recovery_group_edit_view.dart new file mode 100644 index 00000000..82468ee1 --- /dev/null +++ b/lib/src/recovery_group/edit_group/recovery_group_edit_view.dart @@ -0,0 +1,253 @@ +import '/src/core/di_container.dart'; +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/icon_of.dart'; +import '/src/core/model/core_model.dart'; + +import '../widgets/guardian_tile_widget.dart'; +import '../add_guardian/add_guardian_view.dart'; +import '../add_secret/add_secret_view.dart'; +import '../recovery_secret/recovery_secret_view.dart'; + +class RecoveryGroupEditView extends StatelessWidget { + static const routeName = '/recovery_group/edit'; + + final GroupId groupId; + + const RecoveryGroupEditView({super.key, required this.groupId}); + + @override + Widget build(BuildContext context) => ValueListenableBuilder< + Box>( + valueListenable: + context.read().boxRecoveryGroup.listenable(), + builder: (_, boxRecoveryGroup, __) { + final group = boxRecoveryGroup.get(groupId.asKey); + if (group == null) return const Scaffold(); + return ScaffoldWidget( + child: Column( + children: [ + // Header + HeaderBar( + caption: group.name, + backButton: const HeaderBarBackButton(), + closeButton: HeaderBarMoreButton( + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => BottomSheetWidget( + footer: ElevatedButton( + child: const SizedBox( + width: double.infinity, + child: Text( + 'Remove Group', + textAlign: TextAlign.center, + )), + onPressed: () => showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _RemoveGroupConfirmWidget(group: group), + ).then(Navigator.of(context).pop), + ), + ), + ), + ), + ), + // Body + Expanded( + child: ListView( + padding: paddingAll20, + children: [ + // Group is not full + if (group.canAddGuardian) + Container( + decoration: boxDecoration, + padding: paddingAll20, + margin: paddingV20, + child: Column( + children: [ + Padding( + padding: paddingBottom12, + child: Text( + 'Complete the Recovery Group', + style: textStylePoppins620, + textAlign: TextAlign.center, + ), + ), + RichText( + textAlign: TextAlign.center, + text: TextSpan(children: [ + TextSpan( + text: 'Add ${group.neededMore} more Guardian', + style: textStyleBold, + ), + TextSpan( + text: ' to the group via QR Code to split ' + 'and securely share parts of your Secret.', + style: textStyleSourceSansPro416, + ), + ]), + ), + Padding( + padding: paddingTop20, + child: PrimaryButton( + text: 'Add via QR Code', + onPressed: () => Navigator.of(context).pushNamed( + AddGuardianView.routeName, + arguments: groupId, + ), + ), + ), + ], + ), + ), + // Group is full + if (group.canAddSecret) + Container( + decoration: boxDecoration, + padding: paddingAll20, + margin: paddingV20, + child: Column( + children: [ + Padding( + padding: paddingBottom12, + child: Text( + 'Group is ready, let’s add your Secret', + style: textStylePoppins620, + textAlign: TextAlign.center, + ), + ), + Text( + 'Remember: in order to restore your Secret' + ' in the future you’d have to get an approval' + ' from at least ${group.threshold} Guardians.', + style: textStyleSourceSansPro416, + textAlign: TextAlign.center, + ), + Padding( + padding: paddingTop20, + child: PrimaryButton( + text: 'Add Secret', + onPressed: () => Navigator.of(context).pushNamed( + RecoveryGroupAddSecretView.routeName, + arguments: groupId, + ), + ), + ), + ], + ), + ), + if (group.canRecoverSecret) + Padding( + padding: paddingV20, + child: Text( + 'Guardians', + style: textStylePoppins620, + textAlign: TextAlign.center, + ), + ), + // List of Guardians + for (var guardian in group.guardians.values) + Padding( + padding: paddingV6, + child: _GuardianTileWidget(guardian: guardian), + ), + // Group have Secret + if (group.canRecoverSecret) + Padding( + padding: paddingV32, + child: PrimaryButton( + text: 'Recover my Secret', + onPressed: () => Navigator.of(context).pushNamed( + RecoveryGroupRecoverySecretView.routeName, + arguments: groupId, + )), + ), + ], + ), + ), + ], + )); + }); +} + +class _GuardianTileWidget extends StatefulWidget { + const _GuardianTileWidget({required this.guardian}); + + final GuardianModel guardian; + + @override + State<_GuardianTileWidget> createState() => _GuardianTileWidgetState(); +} + +class _GuardianTileWidgetState extends State<_GuardianTileWidget> { + bool? _isWaiting; + + @override + Widget build(BuildContext context) => GestureDetector( + onLongPress: () async { + if (_isWaiting != null) return; // Not yet ready for new ping + final diContainer = context.read(); + setState(() => _isWaiting = true); + final startedAt = DateTime.now(); + final hasPong = await diContainer.networkService.pingPeer( + peerId: widget.guardian.peerId, + staticCheck: false, + ); + if (!mounted) return; + final msElapsed = DateTime.now().difference(startedAt).inMilliseconds; + setState(() => _isWaiting = false); + ScaffoldMessenger.of(context).showSnackBar(buildSnackBar( + isError: !hasPong, + textSpans: hasPong + ? [ + TextSpan(text: widget.guardian.name, style: textStyleBold), + TextSpan(text: ' is online.\nPing $msElapsed ms.'), + ] + : [ + const TextSpan(text: 'Couldn’t reach out to '), + TextSpan(text: widget.guardian.name, style: textStyleBold), + const TextSpan(text: '. Connection timeout.'), + ], + duration: diContainer.globals.snackBarDuration, + )); + Future.delayed( + diContainer.globals.snackBarDuration, + () => mounted ? setState(() => _isWaiting = null) : null, + ); + }, + child: GuardianTileWidget( + guardian: widget.guardian, + isWaiting: _isWaiting ?? false, + ), + ); +} + +class _RemoveGroupConfirmWidget extends StatelessWidget { + final RecoveryGroupModel group; + + const _RemoveGroupConfirmWidget({required this.group}); + + @override + Widget build(BuildContext context) => BottomSheetWidget( + icon: const IconOf.removeGroup(isBig: true, bage: BageType.warning), + titleString: 'Remove Group', + textSpan: [ + const TextSpan(text: 'Are you sure that you want to remove '), + TextSpan(text: group.name, style: textStyleBold), + const TextSpan(text: ' group?'), + ], + footer: SizedBox( + width: double.infinity, + child: PrimaryButton( + text: 'Yes, remove Group', + onPressed: () => context + .read() + .boxRecoveryGroup + .delete(group.id.asKey) + .then((_) => Navigator.of(context).popUntil( + ModalRoute.withName(RecoveryGroupEditView.routeName))), + ), + ), + ); +} diff --git a/lib/src/recovery_group/pages/managed_groups_page.dart b/lib/src/recovery_group/pages/managed_groups_page.dart new file mode 100644 index 00000000..8ce99516 --- /dev/null +++ b/lib/src/recovery_group/pages/managed_groups_page.dart @@ -0,0 +1,72 @@ +import '/src/core/di_container.dart'; +import '/src/core/model/core_model.dart'; +import '/src/core/theme_data.dart'; +import '/src/core/widgets/icon_of.dart'; +import '/src/core/widgets/common.dart'; + +import '../widgets/recovery_group_tile_widget.dart'; +import '../create_group/create_group_view.dart'; + +class ManagedGroupsPage extends StatelessWidget { + const ManagedGroupsPage({super.key}); + + @override + Widget build(BuildContext context) => + ValueListenableBuilder>( + valueListenable: + context.read().boxRecoveryGroup.listenable(), + builder: (_, boxRecoveryGroup, __) => Column( + children: [ + // Header + const HeaderBar(caption: 'My Secrets'), + // Body + if (boxRecoveryGroup.keys.isEmpty) ...[ + const Padding( + padding: paddingTop32, + child: IconOf.secret(isBig: true), + ), + Padding( + padding: paddingV32, + child: Text( + 'You don’t have any Secrets yet', + textAlign: TextAlign.center, + style: textStylePoppins620, + overflow: TextOverflow.clip, + ), + ), + Padding( + padding: paddingV32H20, + child: PrimaryButton( + text: 'Add new Group', + onPressed: () => Navigator.pushNamed( + context, + CreateGroupView.routeName, + ), + ), + ), + ] else + Expanded( + child: ListView( + padding: paddingAll20, + children: [ + for (final group in boxRecoveryGroup.values) + Padding( + padding: paddingV6, + child: RecoveryGroupTileWidget(group: group), + ), + Padding( + padding: paddingV32, + child: PrimaryButton( + text: 'Add new Group', + onPressed: () => Navigator.pushNamed( + context, + CreateGroupView.routeName, + ), + ), + ), + ], + ), + ), + ], + )); +} diff --git a/lib/src/recovery_group/recovery_group_controller.dart b/lib/src/recovery_group/recovery_group_controller.dart new file mode 100644 index 00000000..6bc8d779 --- /dev/null +++ b/lib/src/recovery_group/recovery_group_controller.dart @@ -0,0 +1,64 @@ +import '/src/core/di_container.dart' show SettingsModelExt; +import '/src/core/controller/page_controller.dart'; +import '/src/core/model/core_model.dart'; + +typedef Callback = void Function(MessageModel message); + +class RecoveryGroupController extends PageController { + RecoveryGroupController({ + required super.diContainer, + required super.pagesCount, + }); + + GlobalsModel get globals => diContainer.globals; + + PeerId get myPeerId => + PeerId(value: diContainer.networkService.router.pubKey.data); + + String get myDeviceName => diContainer.boxSettings.deviceName; + + Iterable get storedGroups => diContainer.boxRecoveryGroup.keys; + + Iterable get groups => + diContainer.boxRecoveryGroup.values; + + Stream get networkStream => + diContainer.networkService.recoveryGroupStream; + + void addPeer( + PeerId peerId, + Uint8List address, { + bool enableSearch = false, + }) => + diContainer.networkService + .addPeer(peerId, address, enableSearch: enableSearch); + + Future sendToGuardian(MessageModel message) => + diContainer.networkService.sendToGuardian(message); + + RecoveryGroupModel? getGroupById(GroupId groupId) => + diContainer.boxRecoveryGroup.get(groupId.asKey); + + Future addGroup(RecoveryGroupModel group) async { + await diContainer.boxRecoveryGroup.put(group.id.asKey, group); + notifyListeners(); + return group; + } + + Future removeGroup(GroupId groupId) async { + await diContainer.boxRecoveryGroup.delete(groupId.asKey); + notifyListeners(); + } + + Future addGuardian( + GroupId groupId, + GuardianModel guardian, + ) async { + final group = diContainer.boxRecoveryGroup.get(groupId.asKey); + if (group == null) return null; + final updatedGroup = group.addGuardian(guardian); + await diContainer.boxRecoveryGroup.put(updatedGroup.id.asKey, updatedGroup); + notifyListeners(); + return updatedGroup; + } +} diff --git a/lib/src/recovery_group/recovery_secret/pages/discovering_peers_page.dart b/lib/src/recovery_group/recovery_secret/pages/discovering_peers_page.dart new file mode 100644 index 00000000..776f6f87 --- /dev/null +++ b/lib/src/recovery_group/recovery_secret/pages/discovering_peers_page.dart @@ -0,0 +1,172 @@ +import 'package:wakelock/wakelock.dart'; + +import '/src/core/di_container.dart'; +import '/src/core/model/core_model.dart'; +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/icon_of.dart'; +import '/src/core/widgets/auth.dart'; + +import '../recovery_secret_controller.dart'; +import '../../widgets/guardian_tile_widget.dart'; + +class DiscoveryPeersPage extends StatefulWidget { + const DiscoveryPeersPage({super.key}); + + @override + State createState() => _DiscoveryPeersPageState(); +} + +class _DiscoveryPeersPageState extends State { + @override + void initState() { + super.initState(); + Wakelock.enable(); + context + .read() + .startRequest(onRejected: _showTerminated); + } + + @override + void dispose() { + Wakelock.disable(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = Provider.of(context); + final responses = controller.responses; + final group = controller.group; + return Column( + children: [ + // Header + const HeaderBar( + caption: 'Secret Recovery', + closeButton: HeaderBarCloseButton(), + ), + // Body + Expanded( + child: ListView( + padding: paddingH20, + children: [ + // Card + Padding( + padding: paddingV32, + child: Container( + decoration: boxDecoration, + padding: paddingAll20, + child: Column(children: [ + Padding( + padding: paddingBottom12, + child: Text( + 'Waiting for Guardians', + style: textStylePoppins620, + textAlign: TextAlign.center, + ), + ), + RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: textStyleSourceSansPro414Purple, + children: const [ + TextSpan( + text: + 'Ask Guardians to log into the app and approve' + ' a Secret Recovery. Once they approve, their icon will go '), + TextSpan( + text: 'green.', + style: TextStyle(color: clGreen), + ), + ], + ), + ), + Padding( + padding: paddingTop20, + child: PrimaryButton( + text: 'Access my Secret', + onPressed: controller.shards.length >= group.threshold + ? _askPasscode + : null, + ), + ), + Padding( + padding: paddingTop20, + child: Text( + 'You need to get at least ${group.threshold}' + ' approvals from Guardians to recover your Secret.', + style: textStyleSourceSansPro414Purple, + textAlign: TextAlign.center, + ), + ), + ]), + ), + ), + // Guardians + for (var guardian in group.guardians.values) + Padding( + padding: paddingV6, + child: GuardianTileWidget( + guardian: guardian, + isSuccess: responses[guardian.peerId] == null + ? null + : responses[guardian.peerId] == MessageStatus.accepted, + isWaiting: !responses.containsKey(guardian.peerId), + isOnline: controller.statuses[guardian.peerId] ?? false, + ), + ) + ], + )), + ], + ); + } + + void _askPasscode() { + final passCode = context.read().boxSettings.passCode; + screenLock( + context: context, + correctString: passCode, + canCancel: true, + digits: passCode.length, + keyPadConfig: keyPadConfig, + secretsConfig: secretsConfig, + screenLockConfig: screenLockConfig, + customizedButtonChild: BiometricLogonButton(callback: _pass), + customizedButtonTap: () {}, // Fails if null + title: Padding( + padding: paddingV32H20, + child: Text( + 'Please enter your current passcode', + style: textStylePoppins620, + textAlign: TextAlign.center, + )), + didUnlocked: _pass, + ); + } + + void _pass() { + Navigator.of(context).pop(); + context.read().nextScreen(); + } + + void _showTerminated(MessageModel message) => showModalBottomSheet( + context: context, + isDismissible: false, + isScrollControlled: true, + builder: (context) => BottomSheetWidget( + icon: + const IconOf.secretRestoration(isBig: true, bage: BageType.error), + titleString: 'Guardian rejected the recovery of your Secret', + textString: + 'Secret Recovery process for ${message.secretShard.groupName}' + ' has been terminated by your Guardians.', + footer: Padding( + padding: paddingV20, + child: PrimaryButton( + text: 'Done', + onPressed: Navigator.of(context).pop, + ), + ), + ), + ).then(Navigator.of(context).pop); +} diff --git a/lib/src/recovery_group/recovery_secret/pages/show_secret_page.dart b/lib/src/recovery_group/recovery_secret/pages/show_secret_page.dart new file mode 100644 index 00000000..d8f669d9 --- /dev/null +++ b/lib/src/recovery_group/recovery_secret/pages/show_secret_page.dart @@ -0,0 +1,86 @@ +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; + +import '../recovery_secret_controller.dart'; + +class ShowSecretPage extends StatefulWidget { + const ShowSecretPage({super.key}); + + @override + State createState() => _ShowSecretPageState(); +} + +class _ShowSecretPageState extends State { + bool _isSecretObfuscated = true; + + @override + Widget build(BuildContext context) { + final secret = context.read().secret; + return Column( + children: [ + // Header + const HeaderBar( + caption: 'Secret Recovery', + closeButton: HeaderBarCloseButton(), + ), + // Body + Expanded( + child: ListView( + padding: paddingH20, + children: [ + const PageTitle( + title: 'Here’s your Secret', + subtitle: 'Make sure your display is covered if you want ' + 'to see your Secret.', + ), + // Secret + Card( + child: Padding( + padding: paddingAll20, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + height: 160, + padding: paddingBottom20, + child: _isSecretObfuscated + ? SvgPicture.asset('assets/images/secret_mask.svg') + : Text(secret, + style: textStyleSourceSansPro414Purple), + ), + Row(children: [ + Expanded( + child: ElevatedButton( + child: Text(_isSecretObfuscated ? 'Show' : 'Hide'), + onPressed: () => setState( + () => _isSecretObfuscated = !_isSecretObfuscated), + ), + ), + const SizedBox(width: 12), + Expanded( + child: PrimaryButton( + text: 'Copy', + onPressed: () => Clipboard.setData( + ClipboardData(text: secret)) + .then((_) => ScaffoldMessenger.of(context) + .showSnackBar(buildSnackBar( + text: + 'Secret is copied to your clipboard.'))), + ), + ), + ]), + ], + ), + ), + ), + ], + )), + ], + ); + } +} diff --git a/lib/src/recovery_group/recovery_secret/recovery_secret_controller.dart b/lib/src/recovery_group/recovery_secret/recovery_secret_controller.dart new file mode 100644 index 00000000..04a6a353 --- /dev/null +++ b/lib/src/recovery_group/recovery_secret/recovery_secret_controller.dart @@ -0,0 +1,95 @@ +import 'dart:async'; +import 'package:sss256/sss256.dart'; + +import '/src/core/model/core_model.dart'; + +import '../recovery_group_controller.dart'; + +class RecoverySecretController extends RecoveryGroupController { + final Map _responses = {}; + final Map _subscriptions = {}; + final Map _statuses = {}; + final Set _shards = {}; + final GroupId groupId; + Timer? _timer; + StreamSubscription? _subscription; + + late final RecoveryGroupModel _group; + + RecoverySecretController({ + required super.diContainer, + required super.pagesCount, + required this.groupId, + }) { + _group = getGroupById(groupId)!; + } + + Set get shards => _shards; + + Map get statuses => _statuses; + + Map get responses => _responses; + + RecoveryGroupModel get group => _group; + + String get secret => restoreSecret(shares: shards.toList()); + + @override + void dispose() { + for (var s in _subscriptions.values) { + s.cancel(); + } + _subscription?.cancel(); + _timer?.cancel(); + super.dispose(); + } + + void startRequest({Callback? onRejected}) { + _subscription = networkStream.listen((MessageModel message) { + if (!message.hasResponse) return; + if (message.type != OperationType.getShard) return; + _responses[message.peerId] = message.status; + if (_responses.length == _group.maxSize) { + _timer?.cancel(); + _subscription?.cancel(); + } + if (message.isAccepted && message.secretShard.value.isNotEmpty) { + _shards.add(message.secretShard.value); + } + if (_responses.values.where((e) => e != MessageStatus.accepted).length > + _group.maxSize - _group.threshold) { + onRejected?.call(message); + } + notifyListeners(); + }); + for (var peerId in group.guardians.keys) { + _subscriptions[peerId] = diContainer.networkService.onPeerStatusChanged( + (bool isOnline) { + if (isOnline == _statuses[peerId]) return; + _statuses[peerId] = isOnline; + notifyListeners(); + }, + peerId, + ); + } + _timer = Timer.periodic(globals.retryNetworkTimeout, requestShards); + requestShards(); + } + + Future requestShards([Timer? timer]) async { + if (_shards.length == _group.currentSize) return timer?.cancel(); + final peers = + _group.guardians.keys.toSet().difference(_responses.keys.toSet()); + await Future.wait([ + for (var peer in peers) + sendToGuardian(MessageModel( + peerId: peer, + type: OperationType.getShard, + secretShard: SecretShardModel( + ownerName: myDeviceName, + groupId: _group.id, + ), + )) + ]); + } +} diff --git a/lib/src/recovery_group/recovery_secret/recovery_secret_view.dart b/lib/src/recovery_group/recovery_secret/recovery_secret_view.dart new file mode 100644 index 00000000..e3ea506c --- /dev/null +++ b/lib/src/recovery_group/recovery_secret/recovery_secret_view.dart @@ -0,0 +1,40 @@ +import '/src/core/di_container.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/model/core_model.dart'; + +import 'recovery_secret_controller.dart'; +import 'pages/discovering_peers_page.dart'; +import 'pages/show_secret_page.dart'; + +class RecoveryGroupRecoverySecretView extends StatelessWidget { + static const routeName = '/recovery_group/recovery_secret'; + static const _pages = [ + DiscoveryPeersPage(), + ShowSecretPage(), + ]; + + final GroupId groupId; + + const RecoveryGroupRecoverySecretView({super.key, required this.groupId}); + + @override + Widget build(BuildContext context) { + final diContainer = context.read(); + return ChangeNotifierProvider( + create: (_) => RecoverySecretController( + diContainer: diContainer, + pagesCount: _pages.length, + groupId: groupId, + ), + child: ScaffoldWidget( + child: Selector( + selector: (_, controller) => controller.currentPage, + builder: (_, currentPage, __) => AnimatedSwitcher( + duration: diContainer.globals.pageChangeDuration, + child: _pages[currentPage], + ), + ), + ), + ); + } +} diff --git a/lib/src/recovery_group/restore_group/pages/explainer_page.dart b/lib/src/recovery_group/restore_group/pages/explainer_page.dart new file mode 100644 index 00000000..5356e78e --- /dev/null +++ b/lib/src/recovery_group/restore_group/pages/explainer_page.dart @@ -0,0 +1,54 @@ +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/misc.dart'; + +import '../restore_group_controller.dart'; + +class ExplainerPage extends StatelessWidget { + const ExplainerPage({super.key}); + + @override + Widget build(BuildContext context) => Column( + children: [ + // Header + const HeaderBar( + caption: 'Restore your Group', + closeButton: HeaderBarCloseButton(), + ), + // Body + Expanded( + child: ListView( + padding: paddingH20, + children: [ + const PageTitle( + title: 'Ownership Transfer', subtitle: _screenText), + // Numbered List + Container( + alignment: Alignment.center, + padding: paddingAll20, + decoration: boxDecoration, + child: const NumberedListWidget(list: _screenList), + ), + // Footer + Padding( + padding: paddingV32, + child: PrimaryButton( + text: 'Scan QR Code', + onPressed: context.read().nextScreen, + ), + ), + ], + )), + ], + ); +} + +const _screenText = 'You can restore your Recovery Group with the help of your' + ' Guardians. Even in case you’ve lost access to your device.'; +const _screenList = [ + 'Ask a Guardian within your recovery group to open their Guardian Keyper app', + 'After that, the Guardian should navigate to “Shards” tab and open your group', + 'Direct them to click “Change Owner”', + 'Scan their QR code', + 'Repeat steps above for each Guardian in your recovery group' +]; diff --git a/lib/src/recovery_group/restore_group/pages/loading_page.dart b/lib/src/recovery_group/restore_group/pages/loading_page.dart new file mode 100644 index 00000000..17b17fef --- /dev/null +++ b/lib/src/recovery_group/restore_group/pages/loading_page.dart @@ -0,0 +1,227 @@ +import 'package:wakelock/wakelock.dart'; + +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/widgets/icon_of.dart'; +import '/src/core/model/core_model.dart'; + +import '../restore_group_controller.dart'; + +class LoadingPage extends StatefulWidget { + const LoadingPage({super.key}); + + @override + State createState() => _LoadingPageState(); +} + +class _LoadingPageState extends State { + @override + void initState() { + super.initState(); + Wakelock.enable(); + context.read().startRequest( + onSuccess: _showSuccess, + onReject: _showRejected, + onDuplicate: _showDuplicated, + onFail: _showError, + ); + } + + @override + void dispose() { + Wakelock.disable(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = Provider.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + const HeaderBar( + caption: 'Restore Group', + closeButton: HeaderBarCloseButton(), + ), + // Body + const Padding(padding: paddingTop32), + Padding( + padding: paddingH20, + child: Card( + child: Column( + children: [ + Padding( + padding: paddingTop20, + child: Visibility( + visible: controller.isWaiting, + child: const CircularProgressIndicator.adaptive(), + ), + ), + Padding( + padding: paddingAll20, + child: Text( + 'Awaiting Guardian’s response', + style: textStyleSourceSansPro416, + ), + ), + ], + )), + ), + ], + ); + } + + void _showSuccess(MessageModel message, RecoveryGroupModel group) { + final controller = context.read(); + final count = group.maxSize - group.currentSize; + count == 0 + ? showModalBottomSheet( + context: context, + isDismissible: false, + isScrollControlled: true, + builder: (BuildContext context) => BottomSheetWidget( + icon: const IconOf.secrets(isBig: true, bage: BageType.ok), + titleString: 'Ownership Changed', + textSpan: [ + const TextSpan(text: 'The ownership of the group '), + TextSpan( + text: message.secretShard.groupName, + style: textStyleBold, + ), + const TextSpan(text: ' has been transferred to your device.'), + ], + footer: PrimaryButton( + text: 'Done', + onPressed: Navigator.of(context).pop, + ), + ), + ).then(Navigator.of(context).pop) + : showModalBottomSheet( + context: context, + isDismissible: false, + isScrollControlled: true, + useRootNavigator: true, + builder: (BuildContext context) => BottomSheetWidget( + icon: const IconOf.secrets(isBig: true, bage: BageType.ok), + titleString: 'Ownership Transfer Approved', + textSpan: [ + TextSpan( + text: message.secretShard.ownerName, + style: textStyleBold, + ), + const TextSpan( + text: ' approved the transfer of ownership for the group '), + TextSpan( + text: message.secretShard.groupName, + style: textStyleBold, + ), + ], + body: Padding( + padding: paddingV20, + child: Container( + decoration: boxDecoration, + padding: paddingAll20, + child: RichText( + text: TextSpan( + style: textStyleSourceSansPro416, + children: [ + TextSpan(text: 'Get $count more approvals from other '), + TextSpan( + text: message.secretShard.groupName, + style: textStyleBold, + ), + const TextSpan( + text: ' Guardians to finish the restoration.'), + ], + ), + ), + ), + ), + footer: PrimaryButton( + text: 'Scan New QR Code', + onPressed: () { + Navigator.of(context).pop(); + controller.scanAnotherCode(); + }, + ), + ), + ); + } + + void _showRejected(MessageModel message) => showModalBottomSheet( + context: context, + isDismissible: false, + isScrollControlled: true, + builder: (BuildContext context) => BottomSheetWidget( + icon: const IconOf.secrets(isBig: true, bage: BageType.error), + titleString: 'Ownership Transfer Rejected', + textSpan: [ + TextSpan( + text: message.secretShard.ownerName, + style: textStyleBold, + ), + const TextSpan( + text: ' rejected the transfer of ownership for the group '), + TextSpan( + text: message.secretShard.groupName, + style: textStyleBold, + ), + ], + body: Padding( + padding: paddingV20, + child: Container( + decoration: boxDecoration, + padding: paddingAll20, + child: Text( + 'Since Guardian rejected ownership transfer, ' + 'it is impossible to restore the Group.', + style: textStyleSourceSansPro416Purple, + textAlign: TextAlign.center, + ), + ), + ), + footer: PrimaryButton( + text: 'Close', + onPressed: Navigator.of(context).pop, + ), + ), + ).then(Navigator.of(context).pop); + + void _showDuplicated(MessageModel message) => showModalBottomSheet( + context: context, + isDismissible: false, + isScrollControlled: true, + builder: (BuildContext context) => BottomSheetWidget( + icon: const IconOf.secrets(isBig: true, bage: BageType.error), + titleString: 'Ownership Transfer Failed', + textString: 'You are the owner of this Group already!', + footer: PrimaryButton( + text: 'Close', + onPressed: Navigator.of(context).pop, + ), + ), + ).then(Navigator.of(context).pop); + + void _showError(MessageModel message) { + final controller = context.read(); + showModalBottomSheet( + context: context, + isDismissible: false, + isScrollControlled: true, + builder: (BuildContext context) => BottomSheetWidget( + icon: const IconOf.secrets(isBig: true, bage: BageType.error), + titleString: 'Ownership Transfer Failed', + textString: + 'We couldn’t finish scanning the QR Code.\nPlease try again.', + footer: PrimaryButton( + text: 'Scan QR Code Again', + onPressed: () { + Navigator.of(context).pop(); + controller.scanAnotherCode(); + }, + ), + ), + ); + } +} diff --git a/lib/src/recovery_group/restore_group/pages/scan_qr_code_page.dart b/lib/src/recovery_group/restore_group/pages/scan_qr_code_page.dart new file mode 100644 index 00000000..2fedaf15 --- /dev/null +++ b/lib/src/recovery_group/restore_group/pages/scan_qr_code_page.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +import '../restore_group_controller.dart'; +import '../../widgets/get_qrcode_widget.dart'; + +class ScanQRCodePage extends StatelessWidget { + const ScanQRCodePage({super.key}); + + @override + Widget build(BuildContext context) => GetQRCodeWidget( + resultCallback: context.read().setQRCode, + ); +} diff --git a/lib/src/recovery_group/restore_group/restore_group_controller.dart b/lib/src/recovery_group/restore_group/restore_group_controller.dart new file mode 100644 index 00000000..af8baa18 --- /dev/null +++ b/lib/src/recovery_group/restore_group/restore_group_controller.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import '/src/core/model/core_model.dart'; + +import '../recovery_group_controller.dart'; + +export 'package:provider/provider.dart'; + +enum RestoreGroupResult { success, reject, fail, duplicate } + +class RestoreGroupController extends RecoveryGroupController { + StreamSubscription? _subscription; + Timer? _timer; + QRCode? _qrCode; + bool _isWaiting = true; + + RestoreGroupController({ + required super.diContainer, + required super.pagesCount, + }); + + QRCode? get qrCode => _qrCode; + + bool get isWaiting => _isWaiting; + + @override + void dispose() { + _subscription?.cancel(); + _timer?.cancel(); + super.dispose(); + } + + void startRequest({ + void Function(MessageModel message, RecoveryGroupModel group)? onSuccess, + Callback? onReject, + Callback? onDuplicate, + Callback? onFail, + }) { + _subscription = networkStream.listen((MessageModel message) { + if (!isWaiting) return; + if (!message.hasResponse) return; + if (message.type != OperationType.takeOwnership) return; + if (_qrCode == null || message.peerId != _qrCode!.peerId) return; + _timer?.cancel(); + _subscription?.cancel(); + _isWaiting = false; + notifyListeners(); + if (message.isFailed) return onFail?.call(message); + if (message.isRejected) return onReject?.call(message); + + if (message.isAccepted) { + final guardian = GuardianModel( + name: _qrCode!.peerName, + peerId: _qrCode!.peerId, + ); + final existingGroup = getGroupById(message.secretShard.groupId); + if (existingGroup == null) { + addGroup(RecoveryGroupModel( + id: message.secretShard.groupId, + name: message.secretShard.groupName, + type: RecoveryGroupType.devices, + maxSize: message.secretShard.groupSize, + guardians: {guardian.peerId: guardian}, + isRestoring: true, + hasSecret: true, + )).then((group) => onSuccess?.call(message, group)); + } else if (existingGroup.isNotRestoring) { + onFail?.call(message); + } else if (existingGroup.guardians.containsKey(guardian.peerId)) { + onDuplicate?.call(message); + } else if (existingGroup.isNotFull) { + addGuardian(message.secretShard.groupId, guardian) + .then((group) => onSuccess?.call(message, group!)); + } + } + }); + _timer = Timer.periodic( + globals.retryNetworkTimeout, + sendTakeOwnershipRequest, + ); + sendTakeOwnershipRequest(); + } + + void setQRCode(QRCode qrCode) { + if (_qrCode != null) return; + if (qrCode.type != OperationType.takeOwnership) return; + if (qrCode == _qrCode) return; + _qrCode = qrCode; + nextScreen(); + } + + Future sendTakeOwnershipRequest([Timer? _]) async { + try { + for (var e in qrCode!.addresses) { + addPeer(qrCode!.peerId, e.rawAddress); + } + await sendToGuardian(MessageModel( + peerId: qrCode!.peerId, + type: OperationType.takeOwnership, + nonce: qrCode!.nonce, + secretShard: SecretShardModel(ownerName: myDeviceName), + )); + } catch (_) {} + } + + void scanAnotherCode() { + _qrCode = null; + _isWaiting = true; + previousScreen(); + } +} diff --git a/lib/src/recovery_group/restore_group/restore_group_view.dart b/lib/src/recovery_group/restore_group/restore_group_view.dart new file mode 100644 index 00000000..dec999c5 --- /dev/null +++ b/lib/src/recovery_group/restore_group/restore_group_view.dart @@ -0,0 +1,39 @@ +import '/src/core/di_container.dart'; +import '/src/core/widgets/common.dart'; + +import 'restore_group_controller.dart'; +import 'pages/explainer_page.dart'; +import 'pages/scan_qr_code_page.dart'; +import 'pages/loading_page.dart'; + +class RestoreGroupView extends StatelessWidget { + static const routeName = '/recovery_group/restore'; + static const _pages = [ + ExplainerPage(), + ScanQRCodePage(), + LoadingPage(), + ]; + + const RestoreGroupView({super.key}); + + @override + Widget build(BuildContext context) { + final diContainer = context.read(); + return ChangeNotifierProvider( + create: (context) => RestoreGroupController( + diContainer: diContainer, + pagesCount: _pages.length, + ), + lazy: false, + child: ScaffoldWidget( + child: Selector( + selector: (_, controller) => controller.currentPage, + builder: (_, currentPage, __) => AnimatedSwitcher( + duration: diContainer.globals.pageChangeDuration, + child: _pages[currentPage], + ), + ), + ), + ); + } +} diff --git a/lib/src/recovery_group/widgets/get_qrcode_widget.dart b/lib/src/recovery_group/widgets/get_qrcode_widget.dart new file mode 100644 index 00000000..eb4850f0 --- /dev/null +++ b/lib/src/recovery_group/widgets/get_qrcode_widget.dart @@ -0,0 +1,122 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +import '/src/core/theme_data.dart'; +import '/src/core/widgets/common.dart'; +import '/src/core/model/core_model.dart'; +import '/src/core/di_container.dart'; + +class GetQRCodeWidget extends StatefulWidget { + final void Function(QRCode qrCode) resultCallback; + + const GetQRCodeWidget({super.key, required this.resultCallback}); + + @override + State createState() => _GetQRCodeWidgetState(); +} + +class _GetQRCodeWidgetState extends State { + late final Duration _snackBarDuration; + late final PeerId _myPeerId; + var _scanAreaSize = 0.0; + var _canPaste = false; + Timer? _snackBarTimer; + + @override + void initState() { + final diContainer = context.read(); + _snackBarDuration = diContainer.globals.snackBarDuration; + _myPeerId = diContainer.networkService.myPeerId; + Future.microtask(() async { + if (await Clipboard.hasStrings()) setState(() => _canPaste = true); + }); + super.initState(); + } + + @override + void didChangeDependencies() { + _scanAreaSize = MediaQuery.of(context).size.width * 0.66; + super.didChangeDependencies(); + } + + @override + void dispose() { + _snackBarTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Header + const HeaderBar( + caption: 'Scan the QR Code', + closeButton: HeaderBarCloseButton(), + ), + // Body + Center( + child: SizedBox.square( + dimension: _scanAreaSize, + child: MobileScanner(onDetect: _onDetectCode), + ), + ), + Container( + padding: paddingAll20, + width: double.infinity, + child: ElevatedButton( + onPressed: _canPaste ? _onPasteCode : null, + child: Text( + 'Paste from Clipboard', + style: textStylePoppins616, + ), + ), + ), + ], + ); + + void _onDetectCode(Barcode barcode, MobileScannerArguments? args) { + if (barcode.rawValue != null) _processCode(barcode.rawValue!); + } + + Future _onPasteCode() async { + var code = (await Clipboard.getData(Clipboard.kTextPlain))?.text; + + if (code != null) { + code = code.trim(); + final whiteSpace = code.lastIndexOf('\n'); + code = whiteSpace == -1 ? code : code.substring(whiteSpace).trim(); + _processCode(code); + } + } + + void _processCode(String code) { + SnackBar? errorSnackBar; + final qrCode = QRCode.tryParseBase64(code); + + if (qrCode == null) { + errorSnackBar = buildSnackBar( + text: 'The Code is not valid!\n' + 'Please, make sure if it was generated by Keyper', + isError: true, + ); + } else if (qrCode.peerId == _myPeerId) { + errorSnackBar = buildSnackBar( + text: qrCode.type == OperationType.takeOwnership + ? 'Transferring ownership to a guardian device is not supported yet.' + : 'This operation is not supported yet.', + isError: true, + ); + } + + if (errorSnackBar == null) { + widget.resultCallback(qrCode!); + } else { + // Debounce + if (_snackBarTimer?.isActive ?? false) return; + _snackBarTimer = Timer(_snackBarDuration, () {}); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(errorSnackBar); + } + } +} diff --git a/lib/src/recovery_group/widgets/guardian_tile_widget.dart b/lib/src/recovery_group/widgets/guardian_tile_widget.dart new file mode 100644 index 00000000..e86bfeab --- /dev/null +++ b/lib/src/recovery_group/widgets/guardian_tile_widget.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; + +import '/src/core/theme_data.dart'; +import '/src/core/widgets/icon_of.dart'; +import '/src/core/model/core_model.dart'; + +class GuardianTileWidget extends StatelessWidget { + final GuardianModel guardian; + final bool? isOnline; + final bool? isSuccess; + final bool isWaiting; + + const GuardianTileWidget({ + super.key, + required this.guardian, + this.isOnline, + this.isSuccess, + this.isWaiting = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + decoration: boxDecoration, + padding: paddingV12, + child: Row( + children: [ + // Leading + Padding( + padding: paddingH20, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + IconOf.shield( + color: theme.colorScheme.onSecondary, + bgColor: isSuccess == null + ? null + : isSuccess == true + ? clGreen + : clRed, + ), + Padding( + padding: const EdgeInsets.only(top: 8), + child: Visibility( + visible: isOnline != null, + child: isOnline == true + ? Text( + 'Online', + style: textStyleSourceSansPro412.copyWith( + color: clGreen), + ) + : Text( + 'Offline', + style: textStyleSourceSansPro412.copyWith( + color: clRed), + ), + ), + ), + ], + ), + ), + // Body + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Tag + if (guardian.tag.isNotEmpty) + Container( + decoration: BoxDecoration( + borderRadius: borderRadius, + color: theme.colorScheme.secondary, + ), + child: Text( + ' ${guardian.tag} ', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyleSourceSansPro412Purple, + ), + ), + // Title + Text( + guardian.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyleSourceSansPro614.copyWith( + color: guardian.peerId.value.isEmpty + ? clRed + : theme.colorScheme.primary, + height: 1.5, + ), + ), + // Subtitle + Text( + guardian.peerId.toHexShort(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyleSourceSansPro414Purple, + ), + ], + ), + ), + // Trailing + Padding( + padding: paddingH20, + child: SizedBox( + height: 20, + width: 20, + child: isWaiting + ? const CircularProgressIndicator.adaptive(strokeWidth: 2) + : null, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/recovery_group/widgets/recovery_group_dashboard_button_widget.dart b/lib/src/recovery_group/widgets/recovery_group_dashboard_button_widget.dart new file mode 100644 index 00000000..09146f4e --- /dev/null +++ b/lib/src/recovery_group/widgets/recovery_group_dashboard_button_widget.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '/src/core/di_container.dart'; +import '/src/core/model/core_model.dart'; +import '/src/core/theme_data.dart'; + +class RecoveryGroupDashboardButtonWidget extends StatelessWidget { + const RecoveryGroupDashboardButtonWidget({super.key}); + + @override + Widget build(BuildContext context) => ValueListenableBuilder< + Box>( + valueListenable: + context.read().boxRecoveryGroup.listenable(), + builder: (_, boxRecoveryGroup, __) => Container( + decoration: BoxDecoration( + borderRadius: borderRadius, + color: clGreen, + ), + padding: paddingAll8, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'My secrets', + style: textStyleSourceSansPro612.copyWith(color: clBlack), + ), + Text( + '${boxRecoveryGroup.keys.length} Groups', + style: textStylePoppins616.copyWith(color: clBlack), + ), + ], + ), + const Icon(Icons.arrow_forward_ios_outlined, color: clBlack), + ], + ), + )); +} diff --git a/lib/src/recovery_group/widgets/recovery_group_tile_widget.dart b/lib/src/recovery_group/widgets/recovery_group_tile_widget.dart new file mode 100644 index 00000000..430fca99 --- /dev/null +++ b/lib/src/recovery_group/widgets/recovery_group_tile_widget.dart @@ -0,0 +1,35 @@ +import '/src/core/theme_data.dart'; +import '/src/core/widgets/misc.dart'; +import '/src/core/widgets/icon_of.dart'; +import '/src/core/model/core_model.dart'; + +import '../edit_group/recovery_group_edit_view.dart'; + +class RecoveryGroupTileWidget extends StatelessWidget { + final RecoveryGroupModel group; + + const RecoveryGroupTileWidget({super.key, required this.group}); + + @override + Widget build(BuildContext context) => ListTile( + leading: const IconOf.shield(color: clWhite), + title: Text(group.name, style: textStyleSourceSansPro614), + subtitle: group.hasSecret && group.isNotRestoring + ? Text( + '${group.maxSize} Guardians', + style: textStyleSourceSansPro414, + ) + : Text( + group.isRestoring + ? 'This group is not yet restored' + : 'This group is not yet finished', + style: textStyleSourceSansPro414.copyWith(color: clRed), + ), + trailing: const Icon(Icons.arrow_forward_ios_rounded, color: clWhite), + onTap: () => Navigator.pushNamed( + context, + RecoveryGroupEditView.routeName, + arguments: group.id, + ), + ); +} diff --git a/lib/src/start_view.dart b/lib/src/start_view.dart new file mode 100644 index 00000000..baf798cc --- /dev/null +++ b/lib/src/start_view.dart @@ -0,0 +1,66 @@ +import 'core/theme_data.dart'; +import 'core/di_container.dart'; +import 'core/widgets/auth.dart'; +import 'core/widgets/common.dart'; + +import 'intro/intro_view.dart'; +import 'home/home_view.dart'; + +class StartView extends StatefulWidget { + const StartView({super.key}); + + @override + State createState() => _StartViewState(); +} + +class _StartViewState extends State { + @override + void initState() { + super.initState(); + final diContainer = context.read(); + Future.microtask( + diContainer.boxSettings.passCode.isEmpty + ? () => Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const IntroView())) + : () => screenLock( + context: context, + correctString: diContainer.boxSettings.passCode, + canCancel: false, + digits: diContainer.boxSettings.passCode.length, + keyPadConfig: keyPadConfig, + secretsConfig: secretsConfig, + screenLockConfig: screenLockConfig, + customizedButtonChild: BiometricLogonButton( + callback: () => Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const HomeView()), + (_) => false, + ), + ), + customizedButtonTap: () {}, // Fails if null + title: Padding( + padding: paddingV32, + child: Text( + 'Please enter your current passcode', + style: textStylePoppins620, + textAlign: TextAlign.center, + )), + didUnlocked: () => Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const HomeView()), + (_) => false, + ), + didError: (_) async { + ScaffoldMessenger.of(context).showSnackBar(buildSnackBar( + text: 'Wrong passcode!', + duration: const Duration(seconds: 2), + isFloating: true, + isError: true, + )); + await diContainer.platformService.vibrate(); + }, + ), + ); + } + + @override + Widget build(BuildContext context) => const Scaffold(); +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..b860b87b --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,86 @@ +name: guardian_keyper +description: "A Guardian Network-based secrets backup app" +publish_to: 'none' + +version: 1.1.0+1 + +environment: + sdk: ">=2.18.0 <3.0.0" + flutter: ^3.3.0 + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + flutter_launcher_icons: ^0.10.0 + flutter_native_splash: ^2.2.9 + flutter_secure_storage: ^6.0.0 + flutter_screen_lock: ^7.0.4 + flutter_svg: ^1.1.5 + google_fonts: ^3.0.1 + sentry_flutter: ^6.11.1 + hive_flutter: ^1.1.0 + hive: ^2.2.3 + intl: ^0.17.0 + provider: ^6.0.3 + event_bus: ^2.0.0 + equatable: ^2.0.5 + messagepack: ^0.2.1 + # qr_flutter: ^4.0.0 + qr_flutter: + git: + url: "https://github.com/theyakka/qr.flutter" + + # Crypto + sodium_libs: ^1.2.5 + sss256: ^1.0.0 + + # Platform specific + wakelock: ^0.6.2 + bonsoir: ^2.0.0 + local_auth: ^2.1.2 + mobile_scanner: ^2.0.0 + share_plus: ^4.1.0 + connectivity_plus: ^2.3.6+1 + package_info_plus: ^1.4.3+1 + device_info_plus: ^4.1.2 + airplane_mode_checker: ^1.0.2 + app_settings: ^4.1.8 + vibration: ^1.7.6 + + # P2P networking + p2plib: + git: + url: "https://github.com/GuardianLabs/p2plib-dart.git" + ref: "v0.9.2" + +dev_dependencies: + flutter_lints: ^2.0.1 + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true + + # Enable generation of localized Strings from arb files. + generate: true + + assets: + - assets/images/ + - assets/icons/ + - assets/fonts/ + +flutter_icons: + android: true + ios: true + image_path_ios: "assets/images/logo1024.png" + image_path_android: "assets/images/logo512.png" + +flutter_native_splash: + color: "#1A0244" + color_dark: "#1A0244" + image: assets/images/4.0x/logo.png + image_dark: assets/images/4.0x/logo.png + android_gravity: center + fullscreen: true diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 00000000..2d700ccb --- /dev/null +++ b/test/.gitignore @@ -0,0 +1 @@ +hot_test.dart \ No newline at end of file diff --git a/test/data/filled_models.dart b/test/data/filled_models.dart new file mode 100644 index 00000000..11ceef36 --- /dev/null +++ b/test/data/filled_models.dart @@ -0,0 +1,150 @@ +import 'dart:io' show InternetAddress; +import 'package:guardian_keyper/src/core/model/core_model.dart'; +import 'package:guardian_keyper/src/core/utils/random_utils.dart'; + +//Token model +final peerIdA = PeerId(value: getRandomBytes(64)); +final peerIdB = PeerId(value: getRandomBytes(64)); +final peerIdAA = PeerId(value: peerIdA.value); +final emptyPeerId = PeerId.empty(); + +final groupIdA = GroupId.aNew(); +final groupIdB = GroupId.aNew(); +final groupIdAA = GroupId(value: groupIdA.value); +final emptyGroupId = GroupId.empty(); + +final requestIdA = Nonce.aNew(); +final requestIdB = Nonce.aNew(); +final requestIdAA = Nonce(value: requestIdA.value); +final emptyRequestId = Nonce.empty(); + +final secretIdA = SecretId.aNew(); +final secretIdB = SecretId.aNew(); +final secretIdAA = SecretId(value: secretIdA.value); +final emptySecretId = SecretId.empty(); + +//QRCode model +final qrCode1 = QRCode( + nonce: Nonce.aNew(), + peerId: peerIdA, + type: OperationType.authPeer, + peerName: 'Tester qrCodeA', + addresses: const [], +); +final qrCode2 = QRCode( + nonce: Nonce.aNew(), + peerId: peerIdB, + type: OperationType.authPeer, + peerName: 'Tester qrCodeB', + addresses: [InternetAddress.anyIPv4, InternetAddress.loopbackIPv4], +); + +//Secret shard model +final secretShardA = SecretShardModel( + value: 'TopSecret', + ownerId: peerIdA, + ownerName: 'Alice', + groupId: groupIdA, + groupName: 'to Bob', + groupSize: 3, + groupThreshold: 2, +); +final secretShardB = SecretShardModel( + value: 'TopSecret', + ownerId: peerIdB, + ownerName: 'Bob', + groupId: groupIdB, + groupName: 'to Alice', + groupSize: 3, + groupThreshold: 2, +); +final clearedSecretSecretShardA = secretShardA.copyWith(value: ''); + +//Message model +final p2pPacketA = MessageModel( + peerId: peerIdA, + type: OperationType.getShard, + status: MessageStatus.accepted, + secretShard: secretShardA, +); +final p2pPacketB = MessageModel( + peerId: peerIdB, + type: OperationType.authPeer, + status: MessageStatus.rejected, + secretShard: secretShardB, +); + +final p2pPacketAisProcessedByB = MessageModel( + peerId: peerIdB, + type: OperationType.getShard, + status: MessageStatus.processed, + secretShard: secretShardA.copyWith(ownerId: peerIdB), +); + +//Recovery Group model +final guardianA = GuardianModel( + peerId: peerIdA, + name: 'iPhone', + tag: 'Piece of ...', +); + +final guardianB = GuardianModel( + peerId: peerIdB, + name: 'SuperPhone', + tag: 'My treasure', +); + +const recoveryGroupAName = 'Recovery Group A'; +final recoveryGroupA = RecoveryGroupModel( + id: groupIdA, + name: recoveryGroupAName, + guardians: {guardianA.peerId: guardianA}, +); + +final emptyRecoveryGroupA = RecoveryGroupModel( + id: groupIdA, + name: recoveryGroupAName, + // ignore: prefer_const_literals_to_create_immutables + guardians: {}, +); + +final recoveryGroupAwithGuardianB = RecoveryGroupModel( + id: groupIdA, + name: recoveryGroupAName, + guardians: {guardianA.peerId: guardianA, guardianB.peerId: guardianB}, +); + +final fullRecoveryGroupA = RecoveryGroupModel( + id: groupIdA, + name: recoveryGroupAName, + guardians: {guardianA.peerId: guardianA, guardianB.peerId: guardianB}, + maxSize: 2, +); + +final completedRecoveryGroupA = RecoveryGroupModel( + id: groupIdA, + name: recoveryGroupAName, + guardians: {guardianA.peerId: guardianA}, + hasSecret: true, +); + +final recoveryGroupB = RecoveryGroupModel( + id: groupIdB, + name: 'Recovery Group B', + guardians: {guardianB.peerId: guardianB}, +); + +//Settings Model +const settingsA = SettingsModel( + deviceName: 'device A', + passCode: '123', + isBiometricsEnabled: true, + isProxyEnabled: false, +); + +const settingsB = SettingsModel( + deviceName: 'device B', + passCode: '456', + isBiometricsEnabled: false, + isProxyEnabled: true, +); diff --git a/test/hot_test.dart b/test/hot_test.dart new file mode 100644 index 00000000..636803d5 --- /dev/null +++ b/test/hot_test.dart @@ -0,0 +1,8 @@ +// ignore_for_file: avoid_print +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Hot test', () { + test('Hot test', () {}); + }); +} diff --git a/test/mock/secure_storage.dart b/test/mock/secure_storage.dart new file mode 100644 index 00000000..5f37da6a --- /dev/null +++ b/test/mock/secure_storage.dart @@ -0,0 +1,94 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class SecureStorage implements FlutterSecureStorage { + final _storage = {}; + + @override + Future containsKey({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async => + _storage.containsKey(key); + + @override + Future read({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async => + _storage[key]; + + @override + Future> readAll({ + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async => + _storage; + + @override + Future write({ + required String key, + required String? value, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async => + value == null ? null : _storage[key] = value; + + @override + Future delete({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async => + _storage.remove(key); + + @override + Future deleteAll({ + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async => + _storage.clear(); + + @override + AndroidOptions get aOptions => throw UnimplementedError(); + + @override + IOSOptions get iOptions => throw UnimplementedError(); + + @override + LinuxOptions get lOptions => throw UnimplementedError(); + + @override + MacOsOptions get mOptions => throw UnimplementedError(); + + @override + WindowsOptions get wOptions => throw UnimplementedError(); + + @override + WebOptions get webOptions => throw UnimplementedError(); +} diff --git a/test/model/models_test.dart b/test/model/models_test.dart new file mode 100644 index 00000000..ced48c26 --- /dev/null +++ b/test/model/models_test.dart @@ -0,0 +1,236 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:guardian_keyper/src/core/model/core_model.dart'; + +import '../data/filled_models.dart'; + +void main() { + group('Token model', () { + test( + 'PeerId generation ' + 'should be: A equal AA and not equal B', () { + expect(peerIdA == peerIdAA, true); + expect(peerIdA == peerIdB, false); + }); + test( + 'PeerId.empty ' + 'should be empty', + () => expect(emptyPeerId.length == 0, true)); + test( + 'GroupId ' + 'should be: A equal AA and not equal B', () { + expect(groupIdA == groupIdAA, true); + expect(groupIdA == groupIdB, false); + }); + test( + 'GroupId,empty ' + 'should be empty', + () => expect(emptyGroupId.length == 0, true)); + test( + 'SecretId ' + 'should be: A equal AA and not equal B', () { + expect(secretIdA == secretIdAA, true); + expect(secretIdA == secretIdB, false); + }); + test( + 'SecretId.empty ' + 'should be empty', + () => expect(emptySecretId.length == 0, true)); + test( + 'Nonce ' + 'should be: A equal AA and not equal B', () { + expect(requestIdA == requestIdAA, true); + expect(requestIdA == requestIdB, false); + }); + test( + 'Nonce.empty ' + 'should be empty', + () => expect(emptyRequestId.length == 0, true)); + }); + + group('QRCode model', () { + test( + 'QR generation: should not be equal', + () => expect(qrCode1 == qrCode2, false), + ); + + test( + 'fromBytes/toBytes 1, should be true', + () => expect(QRCode.fromBytes(qrCode1.toBytes()) == qrCode1, true), + ); + test( + 'fromBytes/toBytes 2, should be true', + () => expect(QRCode.fromBytes(qrCode2.toBytes()) == qrCode2, true), + ); + test( + 'fromBytes/toBytes 3, should be false', + () => expect(QRCode.fromBytes(qrCode2.toBytes()) == qrCode1, false), + ); + }); + + group('SecretShardModel model', () { + test('Shard generation: should not be equal', () { + expect(secretShardA == secretShardB, false); + }); + test( + 'toBytes/fromBytes 1, should be equal', + () => expect( + SecretShardModel.fromBytes(secretShardA.toBytes()), secretShardA), + ); + test( + 'toBytes/fromBytes 2, should be equal', + () => expect( + SecretShardModel.fromBytes(secretShardB.toBytes()), secretShardB), + ); + test( + 'toBytes/fromBytes 3, should be false', + () => expect( + SecretShardModel.fromBytes(secretShardB.toBytes()) == secretShardA, + false), + ); + test('toString', () => expect(secretShardA.toString(), 'to Bob of Alice')); + }); + + group('Message model', () { + test( + 'toBytes/fromBytes 1, should be equal', + () => expect( + MessageModel.fromBytes(p2pPacketA.toBytes(), peerIdA), p2pPacketA), + ); + test( + 'toBytes/fromBytes 2, should be equal', + () => expect( + MessageModel.fromBytes(p2pPacketB.toBytes(), peerIdB), p2pPacketB), + ); + test( + 'toBytes/fromBytes 3, should be false', + () => expect( + MessageModel.fromBytes(p2pPacketA.toBytes(), peerIdA) == p2pPacketB, + false), + ); + test( + 'process with same peerId', + () => expect( + p2pPacketA.process(peerIdA) == + p2pPacketA.copyWith(status: MessageStatus.processed), + true)); + test( + 'process changes PeerId', + () => expect( + p2pPacketA.process(peerIdB) == p2pPacketAisProcessedByB, true)); + test( + 'process changes ownerName', + () => expect( + p2pPacketA.process(peerIdA, 'Carol') == + p2pPacketA.copyWith( + status: MessageStatus.processed, + secretShard: secretShardA.copyWith(ownerName: 'Carol')), + true)); + test('clearSecret', () { + expect(p2pPacketA.clearSecret().secretShard.value == '', true); + }); + test('clearSecret changes only secretShard value', () { + final clearedSecretp2pPacketA = + p2pPacketA.copyWith(secretShard: clearedSecretSecretShardA); + expect(clearedSecretp2pPacketA == p2pPacketA.clearSecret(), true); + }); + }); + group('Recovery Group model', () { + test('should not be equal', () { + expect(recoveryGroupA == recoveryGroupB, false); + }); + test( + ' assert: Recovery Group name is empty', + () => expect( + () => RecoveryGroupModel( + id: groupIdA, + name: '', + ), + throwsAssertionError)); + test('toBytes/fromBytes 1, should be true', () { + expect( + RecoveryGroupModel.fromBytes(recoveryGroupA.toBytes()) == + recoveryGroupA, + true); + }); + test('toBytes/fromBytes 2, should be true', () { + expect( + RecoveryGroupModel.fromBytes(recoveryGroupB.toBytes()) == + recoveryGroupB, + true); + }); + test('toBytes/fromBytes 3, should be false', () { + expect( + RecoveryGroupModel.fromBytes(recoveryGroupA.toBytes()) == + recoveryGroupB, + false); + }); + test('addGuardian to empty group', () { + expect( + emptyRecoveryGroupA.addGuardian(guardianA) == recoveryGroupA, true); + }); + test('addGuardian to not empty and not full group', () { + expect( + recoveryGroupA.addGuardian(guardianB) == recoveryGroupAwithGuardianB, + true); + }); + test( + 'addGuardian throw: full group', + () => expect(() => fullRecoveryGroupA.addGuardian(guardianA), + throwsA(isA()))); + test( + 'addGuardian throw: that guardian is already in the group', + () => expect(() => recoveryGroupA.addGuardian(guardianA), + throwsA(isA()))); + test( + 'completeGroup, should be true', + () => expect( + recoveryGroupA.completeGroup() == completedRecoveryGroupA, true)); + test( + 'GuardianModel ' + 'create guardians: should not be equal', () { + expect(guardianA == guardianB, false); + }); + test( + 'GuardianModel ' + 'assert: name is empty', + () => expect( + () => GuardianModel( + peerId: peerIdB, + name: '', + ), + throwsAssertionError)); + test( + 'GuardianModel ' + 'toBytes/fromBytes 1, should be true', + () => expect( + GuardianModel.fromBytes(guardianA.toBytes()) == guardianA, true)); + test( + 'GuardianModel ' + 'toBytes/fromBytes 2, should be true', + () => expect( + GuardianModel.fromBytes(guardianB.toBytes()) == guardianB, true)); + test( + 'GuardianModel ' + 'toBytes/fromBytes 3, should be false', + () => expect( + GuardianModel.fromBytes(guardianA.toBytes()) == guardianB, false)); + }); + + group('SettingsModel', () { + test('should not be equal', () { + expect(settingsA == settingsB, false); + }); + test( + 'toBytes/fromBytes 1, should be true', + () => expect( + SettingsModel.fromBytes(settingsA.toBytes()) == settingsA, true)); + test( + 'toBytes/fromBytes 2, should be true', + () => expect( + SettingsModel.fromBytes(settingsB.toBytes()) == settingsB, true)); + test( + 'toBytes/fromBytes 3, should be false', + () => expect( + SettingsModel.fromBytes(settingsA.toBytes()) == settingsB, false)); + }); +}