commit 9728a0f224a68d9121ba7f0e00e954a1a51c42d9 Author: vishnukvmd Date: Tue Nov 1 11:43:06 2022 +0530 Hello, world diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..6b9372efe --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ + + +## Description + + + +## Type of Change + + + +- [ ] โœจ New feature (non-breaking change which adds functionality) +- [ ] ๐Ÿ› ๏ธ Bug fix (non-breaking change which fixes an issue) +- [ ] โŒ Breaking change (fix or feature that would cause existing functionality to change) +- [ ] ๐Ÿงน Code refactor +- [ ] โœ… Build configuration change +- [ ] ๐Ÿ“ Documentation +- [ ] ๐Ÿ—‘๏ธ Chore diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..23840740b --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Miscellaneous +*.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/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Exceptions to above rules. +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages + +android/key.properties diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..ab8ce4b43 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "thirdparty/sentry-dart"] + path = thirdparty/sentry-dart + url = https://github.com/ente-io/sentry-dart.git + branch = sentry_flutter_ente diff --git a/.metadata b/.metadata new file mode 100644 index 000000000..42069fa1b --- /dev/null +++ b/.metadata @@ -0,0 +1,36 @@ +# 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. + +version: + revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851 + channel: unknown + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851 + base_revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851 + - platform: linux + create_revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851 + base_revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851 + - platform: macos + create_revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851 + base_revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851 + - platform: windows + create_revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851 + base_revision: ee4e09cce01d6f2d7f4baebd247fde02e5008851 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..03e0c3b73 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "dart-code.dart-code", + "dart-code.flutter", + "felixangelov.bloc" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..2d0e16e18 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch development", + "request": "launch", + "type": "dart", + "program": "lib/main.dart", + "args": ["--flavor", "debug", "--target", "lib/main.dart"] + }, + { + "name": "iOS Local", + "request": "launch", + "type": "dart", + "flutterMode": "debug", + "program": "lib/main.dart", + "args": ["--dart-define", "endpoint=http://localhost:8080"] + }, + { + "name": "Launch production", + "request": "launch", + "type": "dart", + "program": "lib/main.dart", + "args": [ + "--target", + "lib/main.dart", + "--dart-define", + "endpoint=http://192.168.1.33:8080" + ] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..fdf729fd9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "workbench.colorCustomizations": { + "activityBar.background": "#44116A", + "titleBar.activeBackground": "#5F1895", + "titleBar.activeForeground": "#FDFBFE" + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..7b93245a8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Very Good Ventures + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..e1c930318 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ente Auth + +Open source authenticator app for your 2FA secrets, with end-to-end encrypted backups. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 000000000..264587f7f --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,51 @@ +# For more linters, we can check https://dart-lang.github.io/linter/lints/index.html +# or https://pub.dev/packages/lint (Effective dart) +# use "flutter analyze ." or "dart analyze ." for running lint checks + +include: package:lints/recommended.yaml +linter: + rules: + # Ref https://github.com/flutter/packages/blob/master/packages/flutter_lints/lib/flutter.yaml + - avoid_print + - avoid_unnecessary_containers + - avoid_web_libraries_in_flutter + - no_logic_in_create_state + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_const_literals_to_create_immutables + - require_trailing_commas + - sized_box_for_whitespace + - use_full_hex_values_for_flutter_colors + - use_key_in_widget_constructors + + - avoid_empty_else + - exhaustive_cases + + # just style suggestions + - sort_pub_dependencies + - use_rethrow_when_possible + - directives_ordering + - always_use_package_imports + +analyzer: + errors: + avoid_empty_else: error + exhaustive_cases: error + curly_braces_in_flow_control_structures: error + directives_ordering: error + always_use_package_imports: error + prefer_final_fields: error + unused_import: error + prefer_is_empty: warning + use_rethrow_when_possible: info + require_trailing_commas: error + + prefer_const_constructors: error # too many warnings + prefer_const_declarations: error # too many warnings + prefer_const_constructors_in_immutables: ignore # too many warnings + + avoid_renaming_method_parameters: ignore # incorrect warnings for `equals` overrides + + exclude: + - thirdparty/** \ No newline at end of file diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 000000000..6f568019d --- /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 000000000..717deee7f --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,124 @@ +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 GradleException("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' +} + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 33 +// compileSdkVersion flutter.compileSdkVersion + + 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 "io.ente.authenticator" + minSdkVersion 20 + targetSdkVersion 30 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + multiDexEnabled true + } + + // TODO: Remove when below fix is available in stable channel. + // https://github.com/flutter/flutter/pull/82309 + lintOptions { + checkReleaseBuilds false + } + + signingConfigs { + if (System.getenv("ANDROID_KEYSTORE_PATH")) { + release { + storeFile file(System.getenv("ANDROID_KEYSTORE_PATH")) + keyAlias System.getenv("ANDROID_KEYSTORE_ALIAS") + keyPassword System.getenv("ANDROID_KEYSTORE_PRIVATE_KEY_PASSWORD") + storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD") + } + } else { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['storePassword'] + } + } + } + + flavorDimensions "default" + productFlavors { + production { + dimension "default" + applicationIdSuffix "" + manifestPlaceholders = [appName: "My App"] + } + staging { + dimension "default" + applicationIdSuffix ".stg" + manifestPlaceholders = [appName: "[STG] My App"] + } + development { + dimension "default" + applicationIdSuffix ".dev" + manifestPlaceholders = [appName: "[DEV] My App"] + } + } + + buildTypes { + release { + signingConfig signingConfigs.release + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android.txt') + } + debug { + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'com.android.support:multidex:1.0.3' + implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava' +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..170493aa8 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/development/ic_launcher-playstore.png b/android/app/src/development/ic_launcher-playstore.png new file mode 100644 index 000000000..87f466206 Binary files /dev/null and b/android/app/src/development/ic_launcher-playstore.png differ diff --git a/android/app/src/development/res/drawable/ic_launcher_foreground.xml b/android/app/src/development/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..0f31f64a1 --- /dev/null +++ b/android/app/src/development/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/android/app/src/development/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/development/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..7353dbd1f --- /dev/null +++ b/android/app/src/development/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/development/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/development/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..7353dbd1f --- /dev/null +++ b/android/app/src/development/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/development/res/mipmap-hdpi/ic_launcher.png b/android/app/src/development/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..af8031442 Binary files /dev/null and b/android/app/src/development/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/development/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/development/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..005f9514a Binary files /dev/null and b/android/app/src/development/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/development/res/mipmap-mdpi/ic_launcher.png b/android/app/src/development/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..1bab3a393 Binary files /dev/null and b/android/app/src/development/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/development/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/development/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..51bac1bde Binary files /dev/null and b/android/app/src/development/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/development/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/development/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..0c02cd604 Binary files /dev/null and b/android/app/src/development/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/development/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/development/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..e8eb3eec7 Binary files /dev/null and b/android/app/src/development/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/development/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/development/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..8b8e0e4bb Binary files /dev/null and b/android/app/src/development/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/development/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/development/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..808d082e9 Binary files /dev/null and b/android/app/src/development/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/development/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/development/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..3084696b7 Binary files /dev/null and b/android/app/src/development/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/development/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/development/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..20bc5baa3 Binary files /dev/null and b/android/app/src/development/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/development/res/values/ic_launcher_background.xml b/android/app/src/development/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..c5d5899fd --- /dev/null +++ b/android/app/src/development/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..ebacc63d5 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 000000000..d920815d3 Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ diff --git a/android/app/src/main/kotlin/io/ente/authenticator/MainActivity.kt b/android/app/src/main/kotlin/io/ente/authenticator/MainActivity.kt new file mode 100644 index 000000000..7a0657427 --- /dev/null +++ b/android/app/src/main/kotlin/io/ente/authenticator/MainActivity.kt @@ -0,0 +1,6 @@ +package io.ente.authenticator + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..cb5d8b81d Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ 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 000000000..e0efb670e 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/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..ac529f74f Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.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 000000000..899cecf22 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 000000000..c82843dfb 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 000000000..87f84c70e 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 000000000..1b5df34e7 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 000000000..3cc4948a1 --- /dev/null +++ b/android/app/src/main/res/drawable-night-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + 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 000000000..ce01bec05 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 000000000..75f4b1f3c 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 000000000..2beb1c816 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 000000000..1b5df34e7 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 000000000..3cc4948a1 --- /dev/null +++ b/android/app/src/main/res/drawable-night/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + 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 000000000..e29b3b59f 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 000000000..3cc4948a1 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..2ef72031a Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ 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 000000000..4bb7a5751 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/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..375aa73e0 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.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 000000000..176f0c723 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/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..ecd9b54e6 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.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 000000000..a0d1a26f7 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 000000000..e29b3b59f Binary files /dev/null and b/android/app/src/main/res/drawable/background.png differ diff --git a/android/app/src/main/res/drawable/ic_launch_image.xml b/android/app/src/main/res/drawable/ic_launch_image.xml new file mode 100644 index 000000000..b49e96743 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launch_image.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..4e37e1072 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,20 @@ + + + + + + + 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 000000000..3cc4948a1 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..7353dbd1f --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..7353dbd1f --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml b/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml new file mode 100644 index 000000000..5f349f7f4 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml @@ -0,0 +1,5 @@ + + + + + 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 000000000..2c681ca34 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-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..000642c6c Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 000000000..6fbcb6df9 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/launcher_icon.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 000000000..5ebc0f5c8 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-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..fac2554a5 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 000000000..13fdf3b88 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/launcher_icon.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 000000000..391f46b75 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-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..8d12806d6 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 000000000..5f852e4a3 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.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 000000000..2b0052936 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-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..479b9682a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 000000000..5c82f386a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.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 000000000..5f55d972c 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/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..2959a5b06 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 000000000..3bea3482c Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.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 000000000..b9921e9a0 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 000000000..f5fdb6b73 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..ab9832824 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #ffffff + \ No newline at end of file diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..c5d5899fd --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ 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 000000000..15a797a2c --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 000000000..170493aa8 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/staging/ic_launcher-playstore.png b/android/app/src/staging/ic_launcher-playstore.png new file mode 100644 index 000000000..c0524bd5f Binary files /dev/null and b/android/app/src/staging/ic_launcher-playstore.png differ diff --git a/android/app/src/staging/res/drawable/ic_launcher_foreground.xml b/android/app/src/staging/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..0f48a6e15 --- /dev/null +++ b/android/app/src/staging/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + diff --git a/android/app/src/staging/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/staging/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..7353dbd1f --- /dev/null +++ b/android/app/src/staging/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/staging/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/staging/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..7353dbd1f --- /dev/null +++ b/android/app/src/staging/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/staging/res/mipmap-hdpi/ic_launcher.png b/android/app/src/staging/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..84db84c01 Binary files /dev/null and b/android/app/src/staging/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/staging/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/staging/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..a982bc031 Binary files /dev/null and b/android/app/src/staging/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/staging/res/mipmap-mdpi/ic_launcher.png b/android/app/src/staging/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..e54346b8f Binary files /dev/null and b/android/app/src/staging/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/staging/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/staging/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..0c9241665 Binary files /dev/null and b/android/app/src/staging/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/staging/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/staging/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..bcb4fbda8 Binary files /dev/null and b/android/app/src/staging/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/staging/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/staging/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..cee8a8827 Binary files /dev/null and b/android/app/src/staging/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/staging/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/staging/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..fc2a1d2c1 Binary files /dev/null and b/android/app/src/staging/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/staging/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/staging/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..eda13fac3 Binary files /dev/null and b/android/app/src/staging/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/staging/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/staging/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..a186cfbf4 Binary files /dev/null and b/android/app/src/staging/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/staging/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/staging/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..71e04d20b Binary files /dev/null and b/android/app/src/staging/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/staging/res/values/ic_launcher_background.xml b/android/app/src/staging/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..c5d5899fd --- /dev/null +++ b/android/app/src/staging/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 000000000..ba6fe4a43 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,29 @@ +buildscript { + ext.kotlin_version = '1.6.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.1.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" + 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 000000000..94adc3a3f --- /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 000000000..cc5527d78 --- /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-7.4-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 000000000..44e62bcf0 --- /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/Inter-Bold.ttf b/assets/fonts/Inter-Bold.ttf new file mode 100644 index 000000000..7e1deec31 Binary files /dev/null and b/assets/fonts/Inter-Bold.ttf differ diff --git a/assets/fonts/Inter-Light.ttf b/assets/fonts/Inter-Light.ttf new file mode 100644 index 000000000..ebaa00574 Binary files /dev/null and b/assets/fonts/Inter-Light.ttf differ diff --git a/assets/fonts/Inter-Medium.ttf b/assets/fonts/Inter-Medium.ttf new file mode 100644 index 000000000..7e573f649 Binary files /dev/null and b/assets/fonts/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 000000000..012d1b470 Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter-SemiBold.ttf b/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 000000000..4be54399d Binary files /dev/null and b/assets/fonts/Inter-SemiBold.ttf differ diff --git a/assets/fonts/Montserrat-Bold.ttf b/assets/fonts/Montserrat-Bold.ttf new file mode 100644 index 000000000..55e0b1a55 Binary files /dev/null and b/assets/fonts/Montserrat-Bold.ttf differ diff --git a/assets/icon-light-adaptive-fg.png b/assets/icon-light-adaptive-fg.png new file mode 100644 index 000000000..c3899f446 Binary files /dev/null and b/assets/icon-light-adaptive-fg.png differ diff --git a/assets/icon-light.png b/assets/icon-light.png new file mode 100644 index 000000000..5ef7b5a8a Binary files /dev/null and b/assets/icon-light.png differ diff --git a/assets/sheild-front-gradient.png b/assets/sheild-front-gradient.png new file mode 100644 index 000000000..86bb62169 Binary files /dev/null and b/assets/sheild-front-gradient.png differ diff --git a/assets/splash-screen-dark.png b/assets/splash-screen-dark.png new file mode 100644 index 000000000..5401a47ad Binary files /dev/null and b/assets/splash-screen-dark.png differ diff --git a/assets/splash-screen-light.png b/assets/splash-screen-light.png new file mode 100644 index 000000000..a97df13b3 Binary files /dev/null and b/assets/splash-screen-light.png differ diff --git a/assets/wallet-front-gradient.png b/assets/wallet-front-gradient.png new file mode 100644 index 000000000..e739dc4be Binary files /dev/null and b/assets/wallet-front-gradient.png differ diff --git a/coverage/lcov.info b/coverage/lcov.info new file mode 100644 index 000000000..26259226a --- /dev/null +++ b/coverage/lcov.info @@ -0,0 +1,73 @@ +SF:lib/app/view/app.dart +DA:7,1 +DA:9,1 +DA:11,1 +DA:12,1 +DA:14,1 +LF:5 +LH:5 +end_of_record +SF:lib/l10n/l10n.dart +DA:7,2 +LF:1 +LH:1 +end_of_record +SF:lib/counter/cubit/counter_cubit.dart +DA:4,2 +DA:6,0 +DA:8,0 +LF:3 +LH:1 +end_of_record +SF:lib/counter/view/counter_page.dart +DA:7,1 +DA:9,1 +DA:11,1 +DA:12,2 +DA:19,1 +DA:21,1 +DA:23,1 +DA:24,1 +DA:25,3 +DA:27,1 +DA:30,1 +DA:31,1 +DA:32,0 +DA:36,1 +DA:37,0 +DA:47,1 +DA:49,1 +DA:51,1 +DA:52,3 +DA:53,4 +LF:20 +LH:18 +end_of_record +SF:lib/main.dart +DA:3,0 +DA:4,0 +DA:8,2 +DA:11,1 +DA:13,1 +DA:15,1 +DA:33,1 +DA:46,1 +DA:47,1 +DA:53,1 +DA:54,2 +DA:60,2 +DA:64,1 +DA:72,1 +DA:73,1 +DA:76,3 +DA:78,1 +DA:81,1 +DA:97,1 +DA:101,1 +DA:102,2 +DA:103,3 +DA:108,1 +DA:109,1 +LF:24 +LH:22 +end_of_record diff --git a/coverage_badge.svg b/coverage_badge.svg new file mode 100644 index 000000000..88bfadfb4 --- /dev/null +++ b/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + \ No newline at end of file diff --git a/fonts/Inter-Bold.ttf b/fonts/Inter-Bold.ttf new file mode 100644 index 000000000..7e1deec31 Binary files /dev/null and b/fonts/Inter-Bold.ttf differ diff --git a/fonts/Inter-Light.ttf b/fonts/Inter-Light.ttf new file mode 100644 index 000000000..ebaa00574 Binary files /dev/null and b/fonts/Inter-Light.ttf differ diff --git a/fonts/Inter-Medium.ttf b/fonts/Inter-Medium.ttf new file mode 100644 index 000000000..7e573f649 Binary files /dev/null and b/fonts/Inter-Medium.ttf differ diff --git a/fonts/Inter-Regular.ttf b/fonts/Inter-Regular.ttf new file mode 100644 index 000000000..012d1b470 Binary files /dev/null and b/fonts/Inter-Regular.ttf differ diff --git a/fonts/Inter-SemiBold.ttf b/fonts/Inter-SemiBold.ttf new file mode 100644 index 000000000..4be54399d Binary files /dev/null and b/fonts/Inter-SemiBold.ttf differ diff --git a/fonts/Montserrat-Bold.ttf b/fonts/Montserrat-Bold.ttf new file mode 100644 index 000000000..55e0b1a55 Binary files /dev/null and b/fonts/Montserrat-Bold.ttf differ diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 000000000..e96ef602b --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,32 @@ +*.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/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 000000000..25fb7fb68 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 9.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 000000000..ec97fc6f3 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 000000000..c4855bfe2 --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 000000000..4c08b2c26 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,45 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' + config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64' + end + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 000000000..7fd41afab --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,159 @@ +PODS: + - connectivity (0.0.1): + - Flutter + - Reachability + - device_info (0.0.1): + - Flutter + - fk_user_agent (2.0.0): + - Flutter + - Flutter (1.0.0) + - flutter_email_sender (0.0.1): + - Flutter + - flutter_inappwebview (0.0.1): + - Flutter + - flutter_inappwebview/Core (= 0.0.1) + - OrderedSet (~> 5.0) + - flutter_inappwebview/Core (0.0.1): + - Flutter + - OrderedSet (~> 5.0) + - flutter_native_splash (0.0.1): + - Flutter + - flutter_secure_storage (3.3.1): + - Flutter + - flutter_sodium (0.0.1): + - Flutter + - fluttertoast (0.0.2): + - Flutter + - Toast + - FMDB (2.7.5): + - FMDB/standard (= 2.7.5) + - FMDB/standard (2.7.5) + - in_app_purchase (0.0.1): + - Flutter + - local_auth (0.0.1): + - Flutter + - move_to_background (0.0.1): + - Flutter + - MTBBarcodeScanner (5.0.11) + - OrderedSet (5.0.0) + - package_info_plus (0.4.5): + - Flutter + - path_provider_ios (0.0.1): + - Flutter + - qr_code_scanner (0.2.0): + - Flutter + - MTBBarcodeScanner + - Reachability (3.2) + - share_plus (0.0.1): + - Flutter + - shared_preferences_ios (0.0.1): + - Flutter + - sqflite (0.0.2): + - Flutter + - FMDB (>= 2.7.5) + - Toast (4.0.0) + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - connectivity (from `.symlinks/plugins/connectivity/ios`) + - device_info (from `.symlinks/plugins/device_info/ios`) + - fk_user_agent (from `.symlinks/plugins/fk_user_agent/ios`) + - Flutter (from `Flutter`) + - flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`) + - flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`) + - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - flutter_sodium (from `.symlinks/plugins/flutter_sodium/ios`) + - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) + - in_app_purchase (from `.symlinks/plugins/in_app_purchase/ios`) + - local_auth (from `.symlinks/plugins/local_auth/ios`) + - move_to_background (from `.symlinks/plugins/move_to_background/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) + - qr_code_scanner (from `.symlinks/plugins/qr_code_scanner/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) + - sqflite (from `.symlinks/plugins/sqflite/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +SPEC REPOS: + trunk: + - FMDB + - MTBBarcodeScanner + - OrderedSet + - Reachability + - Toast + +EXTERNAL SOURCES: + connectivity: + :path: ".symlinks/plugins/connectivity/ios" + device_info: + :path: ".symlinks/plugins/device_info/ios" + fk_user_agent: + :path: ".symlinks/plugins/fk_user_agent/ios" + Flutter: + :path: Flutter + flutter_email_sender: + :path: ".symlinks/plugins/flutter_email_sender/ios" + flutter_inappwebview: + :path: ".symlinks/plugins/flutter_inappwebview/ios" + flutter_native_splash: + :path: ".symlinks/plugins/flutter_native_splash/ios" + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" + flutter_sodium: + :path: ".symlinks/plugins/flutter_sodium/ios" + fluttertoast: + :path: ".symlinks/plugins/fluttertoast/ios" + in_app_purchase: + :path: ".symlinks/plugins/in_app_purchase/ios" + local_auth: + :path: ".symlinks/plugins/local_auth/ios" + move_to_background: + :path: ".symlinks/plugins/move_to_background/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_ios: + :path: ".symlinks/plugins/path_provider_ios/ios" + qr_code_scanner: + :path: ".symlinks/plugins/qr_code_scanner/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_ios: + :path: ".symlinks/plugins/shared_preferences_ios/ios" + sqflite: + :path: ".symlinks/plugins/sqflite/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + connectivity: c4130b2985d4ef6fd26f9702e886bd5260681467 + device_info: d7d233b645a32c40dfdc212de5cf646ca482f175 + fk_user_agent: 1f47ec39291e8372b1d692b50084b0d54103c545 + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b + flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721 + flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef + flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec + flutter_sodium: c84426b4de738514b5b66cfdeb8a06634e72fe0b + fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037 + FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a + in_app_purchase: 3e2155afa9d03d4fa32d9e62d567885080ce97d6 + local_auth: 1740f55d7af0a2e2a8684ce225fe79d8931e808c + move_to_background: 39a5b79b26d577b0372cbe8a8c55e7aa9fcd3a2d + MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb + OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c + package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e + path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 + qr_code_scanner: bb67d64904c3b9658ada8c402e8b4d406d5d796e + Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96 + share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 + shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad + sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 + Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 + url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de + +PODFILE CHECKSUM: b4e3a7eabb03395b66e81fc061789f61526ee6bb + +COCOAPODS: 1.11.3 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000..4494a87d1 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,627 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 51; + 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 */; }; + 91B57D9A86DEE6756350D8D6 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C6D1C42670325932F944AE41 /* Pods_Runner.framework */; }; + 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 */ + 0B7B90FC9BDCCCEAAAB0170B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 12A9F8ECA9536BE19B0E5DC2 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 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 = ""; }; + 6D1099E8B03A8722EBA2F68F /* Pods-Runner.debug-production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug-production.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug-production.xcconfig"; 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 = ""; }; + BFDD67FF3C04C1D881EE6312 /* Pods-Runner.production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.production.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.production.xcconfig"; sourceTree = ""; }; + C6D1C42670325932F944AE41 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 91B57D9A86DEE6756350D8D6 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0ACBB6E499605C090E29FBC7 /* Pods */ = { + isa = PBXGroup; + children = ( + 12A9F8ECA9536BE19B0E5DC2 /* Pods-Runner.profile.xcconfig */, + BFDD67FF3C04C1D881EE6312 /* Pods-Runner.production.xcconfig */, + 6D1099E8B03A8722EBA2F68F /* Pods-Runner.debug-production.xcconfig */, + 0B7B90FC9BDCCCEAAAB0170B /* Pods-Runner.debug.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 80FD4280A269EB513A46C965 /* Frameworks */ = { + isa = PBXGroup; + children = ( + C6D1C42670325932F944AE41 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 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 */, + 0ACBB6E499605C090E29FBC7 /* Pods */, + 80FD4280A269EB513A46C965 /* Frameworks */, + ); + 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 = ( + 95D2A4125BA539B80CD3BD51 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 37D870DE5F4A6DB563928B02 /* [CP] Embed Pods Frameworks */, + ); + 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 */ + 37D870DE5F4A6DB563928B02 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 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"; + }; + 95D2A4125BA539B80CD3BD51 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 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 = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphonesimulator 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; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 6Z68YJY9Q2; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = auth; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.ente.auth; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 276E7D0C28E361A70089C920 /* 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-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 = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Debug; + }; + 276E7D0D28E361A70089C920 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 6Z68YJY9Q2; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = auth; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.ente.auth; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 276E7D1028E361EE0089C920 /* Production */ = { + 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 = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Production; + }; + 276E7D1128E361EE0089C920 /* Production */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 6Z68YJY9Q2; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = auth; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.ente.auth; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Production; + }; + 276E7D1228E362430089C920 /* Debug-production */ = { + 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 = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = "Debug-production"; + }; + 276E7D1328E362430089C920 /* Debug-production */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 6Z68YJY9Q2; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = auth; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = io.ente.auth; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = "Debug-production"; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 249021D3217E4FDB00AE95B9 /* Profile */, + 276E7D1028E361EE0089C920 /* Production */, + 276E7D1228E362430089C920 /* Debug-production */, + 276E7D0C28E361A70089C920 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 249021D4217E4FDB00AE95B9 /* Profile */, + 276E7D1128E361EE0089C920 /* Production */, + 276E7D1328E362430089C920 /* Debug-production */, + 276E7D0D28E361A70089C920 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /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 000000000..18d981003 --- /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 000000000..f9b0d7c5e --- /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 000000000..c87d15a33 --- /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 000000000..21a3cc14c --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /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 000000000..f9b0d7c5e --- /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 000000000..70693e4a8 --- /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 000000000..e882ab988 --- /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 000000000..23ac5355e 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 000000000..233c57d84 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 000000000..8dfb32a97 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 000000000..780cae73a 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 000000000..09f8c298d 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 000000000..d198bb082 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 000000000..90060839d 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 000000000..8dfb32a97 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 000000000..fe8e47ed3 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 000000000..14e9af73d 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 000000000..14e9af73d 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 000000000..21b297f8d 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 000000000..f7ef5fa1b 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 000000000..e2ed1b283 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 000000000..450115a34 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/Contents.json b/ios/Runner/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/ios/Runner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "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 000000000..fa3132785 --- /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 000000000..e29b3b59f 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 000000000..1b5df34e7 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 000000000..f3387d4ae --- /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 000000000..899cecf22 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 000000000..4bb7a5751 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 000000000..176f0c723 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 000000000..87f84c70e 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 000000000..ce01bec05 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 000000000..75f4b1f3c 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 000000000..89c2725b7 --- /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 000000000..8d2b7d51a --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 000000000..6eb59cd66 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 000000000..b7590d913 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,58 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + auth + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + es + + CFBundleName + auth + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSCameraUsageDescription + This app needs camera access to scan QR codes + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 000000000..308a2a560 --- /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 000000000..6f72a55d4 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,4 @@ +arb-dir: lib/l10n/arb +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart +nullable-getter: false diff --git a/lib/app/app.dart b/lib/app/app.dart new file mode 100644 index 000000000..ce4a5aa96 --- /dev/null +++ b/lib/app/app.dart @@ -0,0 +1,2 @@ +// @dart=2.9 +export "view/app.dart"; diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart new file mode 100644 index 000000000..c883fe7a5 --- /dev/null +++ b/lib/app/view/app.dart @@ -0,0 +1,104 @@ +// @dart=2.9 +import 'dart:async'; +import 'dart:io'; + +import 'package:adaptive_theme/adaptive_theme.dart'; +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/events/account_configured_event.dart'; +import 'package:ente_auth/events/user_logged_out_event.dart'; +import "package:ente_auth/l10n/l10n.dart"; +import "package:ente_auth/onboarding/view/onboarding_page.dart"; +import 'package:ente_auth/ui/home_page.dart'; +import 'package:flutter/foundation.dart'; +import "package:flutter/material.dart"; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import "package:flutter_localizations/flutter_localizations.dart"; + +class App extends StatefulWidget { + const App({Key key}); + + @override + State createState() => _AppState(); +} + +class _AppState extends State { + StreamSubscription _loggedOutEvent; + StreamSubscription _accountConfiguredEvent; + + @override + void initState() { + _loggedOutEvent = Bus.instance.on().listen((event) { + if (mounted) { + setState(() {}); + } + }); + _accountConfiguredEvent = + Bus.instance.on().listen((event) { + if (mounted) { + setState(() {}); + } + }); + super.initState(); + } + + @override + void dispose() { + super.dispose(); + _loggedOutEvent.cancel(); + _accountConfiguredEvent.cancel(); + } + + @override + Widget build(BuildContext context) { + if (Platform.isAndroid || kDebugMode) { + return AdaptiveTheme( + light: lightThemeData, + dark: darkThemeData, + initial: AdaptiveThemeMode.system, + builder: (lightTheme, dartTheme) => MaterialApp( + title: "ente", + themeMode: ThemeMode.system, + theme: lightTheme, + darkTheme: dartTheme, + debugShowCheckedModeBanner: false, + builder: EasyLoading.init(), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + routes: _getRoutes, + ), + ); + } else { + return MaterialApp( + title: "ente", + themeMode: ThemeMode.system, + theme: lightThemeData, + darkTheme: darkThemeData, + debugShowCheckedModeBanner: false, + builder: EasyLoading.init(), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + routes: _getRoutes, + ); + } + } + + Map get _getRoutes { + return { + "/": (context) => Configuration.instance.hasConfiguredAccount() + ? const HomePage() + : const OnboardingPage(), + }; + } +} diff --git a/lib/app/view/app_route.dart b/lib/app/view/app_route.dart new file mode 100644 index 000000000..b3cf7bb42 --- /dev/null +++ b/lib/app/view/app_route.dart @@ -0,0 +1,4 @@ +class AppRoute { + static const String enterSecretKeyPage = "enterSecretKeyPage"; + static const String settings = "settings"; +} diff --git a/lib/app/view/app_theme_extension.dart b/lib/app/view/app_theme_extension.dart new file mode 100644 index 000000000..3d39ff555 --- /dev/null +++ b/lib/app/view/app_theme_extension.dart @@ -0,0 +1,165 @@ +import "package:flutter/material.dart"; + +final lightTheme = ThemeData( + fontFamily: "Inter", + brightness: Brightness.light, + scaffoldBackgroundColor: Colors.white, + appBarTheme: const AppBarTheme().copyWith( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + iconTheme: const IconThemeData(color: Colors.black), + elevation: 0, + ), + colorScheme: const ColorScheme.light( + primary: Colors.black, + ), + textTheme: _buildTextTheme(Colors.black), + outlinedButtonTheme: buildOutlinedButtonThemeData( + bgDisabled: Colors.grey.shade500, + bgEnabled: Colors.black, + fgDisabled: Colors.white, + fgEnabled: Colors.white, + ), + inputDecorationTheme: InputDecorationTheme( + fillColor: null, + filled: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(6), + ), + ), +); + +final darkTheme = ThemeData( + fontFamily: "Inter", + brightness: Brightness.dark, + scaffoldBackgroundColor: Colors.black, + backgroundColor: Colors.black, + appBarTheme: const AppBarTheme(color: Colors.orange), + colorScheme: const ColorScheme.dark(primary: Colors.white), + textTheme: _buildTextTheme(Colors.white), + outlinedButtonTheme: buildOutlinedButtonThemeData( + bgDisabled: Colors.grey.shade500, + bgEnabled: Colors.white, + fgDisabled: Colors.white, + fgEnabled: Colors.black, + ), + inputDecorationTheme: InputDecorationTheme( + fillColor: null, + filled: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(6), + ), + ), +); + +TextTheme _buildTextTheme(Color textColor) { + return const TextTheme().copyWith( + headline4: TextStyle( + color: textColor, + fontSize: 32, + fontWeight: FontWeight.w700, + fontFamily: "Inter", + ), + headline5: TextStyle( + color: textColor, + fontSize: 24, + fontWeight: FontWeight.w600, + fontFamily: "Inter", + ), + // AG: Body + headline6: TextStyle( + color: textColor, + fontSize: 18, + fontFamily: "Inter", + fontWeight: FontWeight.w500, + ), + // Use labels for buttons or notifications + labelMedium: TextStyle( + color: textColor, + fontFamily: "Inter", + fontSize: 18, + fontWeight: FontWeight.w700, + height: 28, + ), + + subtitle1: TextStyle( + color: textColor, + fontFamily: "Inter", + fontSize: 16, + fontWeight: FontWeight.w500, + ), + subtitle2: TextStyle( + color: textColor, + fontFamily: "Inter", + fontSize: 14, + fontWeight: FontWeight.w500, + ), + bodyText1: TextStyle( + fontFamily: "Inter", + color: textColor, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + bodyText2: TextStyle( + fontFamily: "Inter", + color: textColor, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + caption: TextStyle( + color: textColor.withOpacity(0.6), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ); +} + +OutlinedButtonThemeData buildOutlinedButtonThemeData({ + required Color bgDisabled, + required Color bgEnabled, + required Color fgDisabled, + required Color fgEnabled, +}) { + return OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 50), + textStyle: const TextStyle( + fontWeight: FontWeight.w700, + fontFamily: "Inter", + fontSize: 18, + ), + ).copyWith( + backgroundColor: MaterialStateProperty.resolveWith( + (Set states) { + if (states.contains(MaterialState.disabled)) { + return bgDisabled; + } + return bgEnabled; + }, + ), + foregroundColor: MaterialStateProperty.resolveWith( + (Set states) { + if (states.contains(MaterialState.disabled)) { + return fgDisabled; + } + return fgEnabled; + }, + ), + alignment: Alignment.center, + ), + ); +} diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart new file mode 100644 index 000000000..e423ba7c8 --- /dev/null +++ b/lib/bootstrap.dart @@ -0,0 +1,35 @@ +import "dart:async"; +import "dart:developer"; + +import "package:bloc/bloc.dart"; +import "package:flutter/widgets.dart"; + +class AppBlocObserver extends BlocObserver { + @override + void onChange(BlocBase bloc, Change change) { + super.onChange(bloc, change); + log("onChange(${bloc.runtimeType}, $change)"); + } + + @override + void onError(BlocBase bloc, Object error, StackTrace stackTrace) { + log("onError(${bloc.runtimeType}, $error, $stackTrace)"); + super.onError(bloc, error, stackTrace); + } +} + +Future bootstrap(FutureOr Function() builder) async { + FlutterError.onError = (details) { + log(details.exceptionAsString(), stackTrace: details.stack); + }; + + await runZonedGuarded( + () async { + await BlocOverrides.runZoned( + () async => runApp(await builder()), + blocObserver: AppBlocObserver(), + ); + }, + (error, stackTrace) => log(error.toString(), stackTrace: stackTrace), + ); +} diff --git a/lib/core/configuration.dart b/lib/core/configuration.dart new file mode 100644 index 000000000..f26ea5134 --- /dev/null +++ b/lib/core/configuration.dart @@ -0,0 +1,505 @@ +import 'dart:convert'; +import 'dart:io' as io; +import 'dart:typed_data'; + +import 'package:bip39/bip39.dart' as bip39; +import 'package:ente_auth/core/constants.dart'; +// import 'package:ente_auth/core/error-reporting/super_logging.dart'; +import 'package:ente_auth/core/errors.dart'; +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/events/signed_in_event.dart'; +import 'package:ente_auth/events/user_logged_out_event.dart'; +import 'package:ente_auth/models/key_attributes.dart'; +import 'package:ente_auth/models/key_gen_result.dart'; +import 'package:ente_auth/models/private_key_attributes.dart'; +import 'package:ente_auth/store/authenticator_db.dart'; +import 'package:ente_auth/utils/crypto_util.dart'; +// import 'package:ente_auth/utils/validator_util.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_sodium/flutter_sodium.dart'; +import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +class Configuration { + Configuration._privateConstructor(); + + static final Configuration instance = Configuration._privateConstructor(); + static const endpoint = String.fromEnvironment( + "endpoint", + defaultValue: kDefaultProductionEndpoint, + ); + static const emailKey = "email"; + static const keyAttributesKey = "key_attributes"; + static const keyKey = "key"; + static const keyShouldHideFromRecents = "should_hide_from_recents"; + static const keyShouldShowLockScreen = "should_show_lock_screen"; + static const lastTempFolderClearTimeKey = "last_temp_folder_clear_time"; + static const secretKeyKey = "secret_key"; + static const authSecretKeyKey = "auth_secret_key"; + static const tokenKey = "token"; + static const encryptedTokenKey = "encrypted_token"; + static const userIDKey = "user_id"; + static const hasMigratedSecureStorageToFirstUnlockKey = + "has_migrated_secure_storage_to_first_unlock"; + static const anonymousUserIDKey = "anonymous_user_id"; + + final kTempFolderDeletionTimeBuffer = const Duration(days: 1).inMicroseconds; + + static final _logger = Logger("Configuration"); + + String? _cachedToken; + late String _documentsDirectory; + String? _key; + late SharedPreferences _preferences; + String? _secretKey; + String? _authSecretKey; + late FlutterSecureStorage _secureStorage; + late String _tempDirectory; + late String _thumbnailCacheDirectory; + + // 6th July 22: Remove this after 3 months. Hopefully, active users + // will migrate to newer version of the app, where shared media is stored + // on appSupport directory which OS won't clean up automatically + late String _sharedTempMediaDirectory; + + late String _sharedDocumentsMediaDirectory; + String? _volatilePassword; + + final _secureStorageOptionsIOS = + const IOSOptions(accessibility: KeychainAccessibility.first_unlock); + + // const IOSOptions(accessibility: IOSAccessibility.first_unlock); + + Future init() async { + _preferences = await SharedPreferences.getInstance(); + _secureStorage = const FlutterSecureStorage(); + _documentsDirectory = (await getApplicationDocumentsDirectory()).path; + _tempDirectory = _documentsDirectory + "/temp/"; + final tempDirectory = io.Directory(_tempDirectory); + try { + final currentTime = DateTime.now().microsecondsSinceEpoch; + if (tempDirectory.existsSync() && + (_preferences.getInt(lastTempFolderClearTimeKey) ?? 0) < + (currentTime - kTempFolderDeletionTimeBuffer)) { + await tempDirectory.delete(recursive: true); + await _preferences.setInt(lastTempFolderClearTimeKey, currentTime); + _logger.info("Cleared temp folder"); + } else { + _logger.info("Skipping temp folder clear"); + } + } catch (e) { + _logger.warning(e); + } + tempDirectory.createSync(recursive: true); + final tempDirectoryPath = (await getTemporaryDirectory()).path; + _thumbnailCacheDirectory = tempDirectoryPath + "/thumbnail-cache"; + io.Directory(_thumbnailCacheDirectory).createSync(recursive: true); + _sharedTempMediaDirectory = tempDirectoryPath + "/ente-shared-media"; + io.Directory(_sharedTempMediaDirectory).createSync(recursive: true); + _sharedDocumentsMediaDirectory = _documentsDirectory + "/ente-shared-media"; + io.Directory(_sharedDocumentsMediaDirectory).createSync(recursive: true); + if (!_preferences.containsKey(tokenKey)) { + await _secureStorage.deleteAll(iOptions: _secureStorageOptionsIOS); + } else { + _key = await _secureStorage.read( + key: keyKey, + iOptions: _secureStorageOptionsIOS, + ); + _secretKey = await _secureStorage.read( + key: secretKeyKey, + iOptions: _secureStorageOptionsIOS, + ); + _authSecretKey = await _secureStorage.read( + key: authSecretKeyKey, + iOptions: _secureStorageOptionsIOS, + ); + if (_key == null) { + await logout(autoLogout: true); + } + await _migrateSecurityStorageToFirstUnlock(); + } + // SuperLogging.setUserID(await _getOrCreateAnonymousUserID()); + } + + Future logout({bool autoLogout = false}) async { + await _preferences.clear(); + await _secureStorage.deleteAll(iOptions: _secureStorageOptionsIOS); + await AuthenticatorDB.instance.clearTable(); + _key = null; + _cachedToken = null; + _secretKey = null; + Bus.instance.fire(UserLoggedOutEvent()); + } + + Future generateKey(String password) async { + // Create a master key + final masterKey = CryptoUtil.generateKey(); + + // Create a recovery key + final recoveryKey = CryptoUtil.generateKey(); + + // Encrypt master key and recovery key with each other + final encryptedMasterKey = CryptoUtil.encryptSync(masterKey, recoveryKey); + final encryptedRecoveryKey = CryptoUtil.encryptSync(recoveryKey, masterKey); + + // Derive a key from the password that will be used to encrypt and + // decrypt the master key + final kekSalt = CryptoUtil.getSaltToDeriveKey(); + final derivedKeyResult = await CryptoUtil.deriveSensitiveKey( + utf8.encode(password) as Uint8List, + kekSalt, + ); + + // Encrypt the key with this derived key + final encryptedKeyData = + CryptoUtil.encryptSync(masterKey, derivedKeyResult.key); + + // Generate a public-private keypair and encrypt the latter + final keyPair = await CryptoUtil.generateKeyPair(); + final encryptedSecretKeyData = + CryptoUtil.encryptSync(keyPair.sk, masterKey); + + final attributes = KeyAttributes( + Sodium.bin2base64(kekSalt), + Sodium.bin2base64(encryptedKeyData.encryptedData!), + Sodium.bin2base64(encryptedKeyData.nonce!), + Sodium.bin2base64(keyPair.pk), + Sodium.bin2base64(encryptedSecretKeyData.encryptedData!), + Sodium.bin2base64(encryptedSecretKeyData.nonce!), + derivedKeyResult.memLimit, + derivedKeyResult.opsLimit, + Sodium.bin2base64(encryptedMasterKey.encryptedData!), + Sodium.bin2base64(encryptedMasterKey.nonce!), + Sodium.bin2base64(encryptedRecoveryKey.encryptedData!), + Sodium.bin2base64(encryptedRecoveryKey.nonce!), + ); + final privateAttributes = PrivateKeyAttributes( + Sodium.bin2base64(masterKey), + Sodium.bin2hex(recoveryKey), + Sodium.bin2base64(keyPair.sk), + ); + return KeyGenResult(attributes, privateAttributes); + } + + Future updatePassword(String password) async { + // Get master key + final masterKey = getKey(); + + // Derive a key from the password that will be used to encrypt and + // decrypt the master key + final kekSalt = CryptoUtil.getSaltToDeriveKey(); + final derivedKeyResult = await CryptoUtil.deriveSensitiveKey( + utf8.encode(password) as Uint8List, + kekSalt, + ); + + // Encrypt the key with this derived key + final encryptedKeyData = + CryptoUtil.encryptSync(masterKey!, derivedKeyResult.key); + + final existingAttributes = getKeyAttributes(); + + return existingAttributes!.copyWith( + kekSalt: Sodium.bin2base64(kekSalt), + encryptedKey: Sodium.bin2base64(encryptedKeyData.encryptedData!), + keyDecryptionNonce: Sodium.bin2base64(encryptedKeyData.nonce!), + memLimit: derivedKeyResult.memLimit, + opsLimit: derivedKeyResult.opsLimit, + ); + } + + Future decryptAndSaveSecrets( + String password, + KeyAttributes attributes, + ) async { + _logger.info('Start decryptAndSaveSecrets'); + // validatePreVerificationStateCheck( + // attributes, + // password, + // getEncryptedToken(), + // ); + _logger.info('state validation done'); + final kek = await CryptoUtil.deriveKey( + utf8.encode(password) as Uint8List, + Sodium.base642bin(attributes.kekSalt), + attributes.memLimit, + attributes.opsLimit, + ).onError((e, s) { + _logger.severe('deriveKey failed', e, s); + throw KeyDerivationError(); + }); + + _logger.info('user-key done'); + Uint8List key; + try { + key = CryptoUtil.decryptSync( + Sodium.base642bin(attributes.encryptedKey), + kek, + Sodium.base642bin(attributes.keyDecryptionNonce), + ); + } catch (e) { + _logger.severe('master-key failed, incorrect password?', e); + throw Exception("Incorrect password"); + } + _logger.info("master-key done"); + await setKey(Sodium.bin2base64(key)); + final secretKey = CryptoUtil.decryptSync( + Sodium.base642bin(attributes.encryptedSecretKey), + key, + Sodium.base642bin(attributes.secretKeyDecryptionNonce), + ); + _logger.info("secret-key done"); + await setSecretKey(Sodium.bin2base64(secretKey)); + final token = CryptoUtil.openSealSync( + Sodium.base642bin(getEncryptedToken()!), + Sodium.base642bin(attributes.publicKey), + secretKey, + ); + _logger.info('appToken done'); + await setToken( + Sodium.bin2base64(token, variant: Sodium.base64VariantUrlsafe), + ); + } + + Future recover(String recoveryKey) async { + // check if user has entered mnemonic code + if (recoveryKey.contains(' ')) { + if (recoveryKey.split(' ').length != mnemonicKeyWordCount) { + throw AssertionError( + 'recovery code should have $mnemonicKeyWordCount words', + ); + } + recoveryKey = bip39.mnemonicToEntropy(recoveryKey); + } + final attributes = getKeyAttributes(); + Uint8List masterKey; + try { + masterKey = await CryptoUtil.decrypt( + Sodium.base642bin(attributes!.masterKeyEncryptedWithRecoveryKey), + Sodium.hex2bin(recoveryKey), + Sodium.base642bin(attributes.masterKeyDecryptionNonce), + ); + } catch (e) { + _logger.severe(e); + rethrow; + } + await setKey(Sodium.bin2base64(masterKey)); + final secretKey = CryptoUtil.decryptSync( + Sodium.base642bin(attributes.encryptedSecretKey), + masterKey, + Sodium.base642bin(attributes.secretKeyDecryptionNonce), + ); + await setSecretKey(Sodium.bin2base64(secretKey)); + final token = CryptoUtil.openSealSync( + Sodium.base642bin(getEncryptedToken()!), + Sodium.base642bin(attributes.publicKey), + secretKey, + ); + await setToken( + Sodium.bin2base64(token, variant: Sodium.base64VariantUrlsafe), + ); + } + + String getHttpEndpoint() { + return endpoint; + } + + String? getToken() { + _cachedToken ??= _preferences.getString(tokenKey); + return _cachedToken; + } + + Future setToken(String token) async { + _cachedToken = token; + await _preferences.setString(tokenKey, token); + Bus.instance.fire(SignedInEvent()); + } + + Future setEncryptedToken(String encryptedToken) async { + await _preferences.setString(encryptedTokenKey, encryptedToken); + } + + String? getEncryptedToken() { + return _preferences.getString(encryptedTokenKey); + } + + String? getEmail() { + return _preferences.getString(emailKey); + } + + Future setEmail(String email) async { + await _preferences.setString(emailKey, email); + } + + int? getUserID() { + return _preferences.getInt(userIDKey); + } + + Future setUserID(int userID) async { + await _preferences.setInt(userIDKey, userID); + } + + Future setKeyAttributes(KeyAttributes attributes) async { + await _preferences.setString(keyAttributesKey, attributes.toJson()); + } + + KeyAttributes? getKeyAttributes() { + final jsonValue = _preferences.getString(keyAttributesKey); + if (jsonValue == null) { + return null; + } else { + return KeyAttributes.fromJson(jsonValue); + } + } + + Future setKey(String? key) async { + _key = key; + if (key == null) { + await _secureStorage.delete( + key: keyKey, + iOptions: _secureStorageOptionsIOS, + ); + } else { + await _secureStorage.write( + key: keyKey, + value: key, + iOptions: _secureStorageOptionsIOS, + ); + } + } + + Future setSecretKey(String? secretKey) async { + _secretKey = secretKey; + if (secretKey == null) { + await _secureStorage.delete( + key: secretKeyKey, + iOptions: _secureStorageOptionsIOS, + ); + } else { + await _secureStorage.write( + key: secretKeyKey, + value: secretKey, + iOptions: _secureStorageOptionsIOS, + ); + } + } + + Future setAuthSecretKey(String? authSecretKey) async { + _authSecretKey = authSecretKey; + if (authSecretKey == null) { + await _secureStorage.delete( + key: authSecretKeyKey, + iOptions: _secureStorageOptionsIOS, + ); + } else { + await _secureStorage.write( + key: authSecretKeyKey, + value: authSecretKey, + iOptions: _secureStorageOptionsIOS, + ); + } + } + + Uint8List? getKey() { + return _key == null ? null : Sodium.base642bin(_key!); + } + + Uint8List? getSecretKey() { + return _secretKey == null ? null : Sodium.base642bin(_secretKey!); + } + + Uint8List? getAuthSecretKey() { + return _authSecretKey == null ? null : Sodium.base642bin(_authSecretKey!); + } + + Uint8List getRecoveryKey() { + final keyAttributes = getKeyAttributes()!; + return CryptoUtil.decryptSync( + Sodium.base642bin(keyAttributes.recoveryKeyEncryptedWithMasterKey), + getKey(), + Sodium.base642bin(keyAttributes.recoveryKeyDecryptionNonce), + ); + } + + // Caution: This directory is cleared on app start + String getTempDirectory() { + return _tempDirectory; + } + + String getThumbnailCacheDirectory() { + return _thumbnailCacheDirectory; + } + + String getOldSharedMediaCacheDirectory() { + return _sharedTempMediaDirectory; + } + + String getSharedMediaDirectory() { + return _sharedDocumentsMediaDirectory; + } + + bool hasConfiguredAccount() { + return getToken() != null && _key != null; + } + + bool shouldShowLockScreen() { + if (_preferences.containsKey(keyShouldShowLockScreen)) { + return _preferences.getBool(keyShouldShowLockScreen)!; + } else { + return false; + } + } + + Future setShouldShowLockScreen(bool value) { + return _preferences.setBool(keyShouldShowLockScreen, value); + } + + bool shouldHideFromRecents() { + return _preferences.getBool(keyShouldHideFromRecents) ?? false; + } + + Future setShouldHideFromRecents(bool value) { + return _preferences.setBool(keyShouldHideFromRecents, value); + } + + void setVolatilePassword(String volatilePassword) { + _volatilePassword = volatilePassword; + } + + String? getVolatilePassword() { + return _volatilePassword; + } + + Future _migrateSecurityStorageToFirstUnlock() async { + final hasMigratedSecureStorageToFirstUnlock = + _preferences.getBool(hasMigratedSecureStorageToFirstUnlockKey) ?? false; + if (!hasMigratedSecureStorageToFirstUnlock && + _key != null && + _secretKey != null) { + await _secureStorage.write( + key: keyKey, + value: _key, + iOptions: _secureStorageOptionsIOS, + ); + await _secureStorage.write( + key: secretKeyKey, + value: _secretKey, + iOptions: _secureStorageOptionsIOS, + ); + await _preferences.setBool( + hasMigratedSecureStorageToFirstUnlockKey, + true, + ); + } + } + + Future _getOrCreateAnonymousUserID() async { + if (!_preferences.containsKey(anonymousUserIDKey)) { + //ignore: prefer_const_constructors + await _preferences.setString(anonymousUserIDKey, Uuid().v4()); + } + return _preferences.getString(anonymousUserIDKey)!; + } +} diff --git a/lib/core/constants.dart b/lib/core/constants.dart new file mode 100644 index 000000000..a51250130 --- /dev/null +++ b/lib/core/constants.dart @@ -0,0 +1,43 @@ +const int thumbnailSmallSize = 256; +const int thumbnailQuality = 50; +const int thumbnailLargeSize = 512; +const int compressedThumbnailResolution = 1080; +const int thumbnailDataLimit = 100 * 1024; +const String sentryDSN = + "https://2235e5c99219488ea93da34b9ac1cb68@sentry.ente.io/4"; +const String sentryDebugDSN = + "https://ca5e686dd7f149d9bf94e620564cceba@sentry.ente.io/3"; +const String sentryTunnel = "https://sentry-reporter.ente.io"; +const String roadmapURL = "https://roadmap.ente.io"; +const int microSecondsInDay = 86400000000; +const int android11SDKINT = 30; +const int galleryLoadStartTime = -8000000000000000; // Wednesday, March 6, 1748 +const int galleryLoadEndTime = 9223372036854775807; // 2^63 -1 + +// used to identify which ente file are available in app cache +// todo: 6Jun22: delete old media identifier after 3 months +const String oldSharedMediaIdentifier = 'ente-shared://'; +const String sharedMediaIdentifier = 'ente-shared-media://'; + +const int maxLivePhotoToastCount = 2; +const String livePhotoToastCounterKey = "show_live_photo_toast"; + +const thumbnailDiskLoadDeferDuration = Duration(milliseconds: 40); +const thumbnailServerLoadDeferDuration = Duration(milliseconds: 80); + +// 256 bit key maps to 24 words +// https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki#Generating_the_mnemonic +const mnemonicKeyWordCount = 24; + +// https://stackoverflow.com/a/61162219 +const dragSensitivity = 8; + +const supportEmail = 'support@ente.io'; + +// Default values for various feature flags +class FFDefault { + static const bool enableStripe = true; + static const bool disableCFWorker = false; +} + +const kDefaultProductionEndpoint = 'https://api.ente.io'; diff --git a/lib/core/errors.dart b/lib/core/errors.dart new file mode 100644 index 000000000..08df97ff5 --- /dev/null +++ b/lib/core/errors.dart @@ -0,0 +1,44 @@ +class InvalidFileError extends ArgumentError { + InvalidFileError(String message) : super(message); +} + +class InvalidFileUploadState extends AssertionError { + InvalidFileUploadState(String message) : super(message); +} + +class SubscriptionAlreadyClaimedError extends Error {} + +class WiFiUnavailableError extends Error {} + +class SyncStopRequestedError extends Error {} + +class NoActiveSubscriptionError extends Error {} + +class StorageLimitExceededError extends Error {} + +// error when file size + current usage >= storage plan limit + buffer +class FileTooLargeForPlanError extends Error {} + +class SilentlyCancelUploadsError extends Error {} + +class UserCancelledUploadError extends Error {} + +class LockAlreadyAcquiredError extends Error {} + +class UnauthorizedError extends Error {} + +class RequestCancelledError extends Error {} + +class InvalidSyncStatusError extends AssertionError { + InvalidSyncStatusError(String message) : super(message); +} + +class UnauthorizedEditError extends AssertionError {} + +class InvalidStateError extends AssertionError { + InvalidStateError(String message) : super(message); +} + +class KeyDerivationError extends Error {} + +class AuthenticatorKeyNotFound extends Error {} diff --git a/lib/core/event_bus.dart b/lib/core/event_bus.dart new file mode 100644 index 000000000..162a10743 --- /dev/null +++ b/lib/core/event_bus.dart @@ -0,0 +1,5 @@ +import 'package:event_bus/event_bus.dart'; + +class Bus { + static final EventBus instance = EventBus(); +} diff --git a/lib/core/logging/super_logging.dart b/lib/core/logging/super_logging.dart new file mode 100644 index 000000000..709777267 --- /dev/null +++ b/lib/core/logging/super_logging.dart @@ -0,0 +1,377 @@ +// @dart=2.9 + +library super_logging; + +import 'dart:async'; +import 'dart:collection'; +import 'dart:core'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:logging/logging.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; + +typedef FutureOrVoidCallback = FutureOr Function(); + +extension SuperString on String { + Iterable chunked(int chunkSize) sync* { + var start = 0; + + while (true) { + final stop = start + chunkSize; + if (stop > length) break; + yield substring(start, stop); + start = stop; + } + + if (start < length) { + yield substring(start); + } + } +} + +extension SuperLogRecord on LogRecord { + String toPrettyString([String extraLines]) { + final header = "[$loggerName] [$level] [$time]"; + + var msg = "$header $message"; + + if (error != null) { + msg += "\nโคท type: ${error.runtimeType}\nโคท error: $error"; + } + if (stackTrace != null) { + msg += "\nโคท trace: $stackTrace"; + } + + for (var line in extraLines?.split('\n') ?? []) { + msg += '\n$header $line'; + } + + return msg; + } +} + +class LogConfig { + /// The DSN for a Sentry app. + /// This can be obtained from the Sentry apps's "settings > Client Keys (DSN)" page. + /// + /// Only logs containing errors are sent to sentry. + /// Errors can be caught using a try-catch block, like so: + /// + /// ``` + /// final logger = Logger("main"); + /// + /// try { + /// // do something dangerous here + /// } catch(e, trace) { + /// logger.info("Huston, we have a problem", e, trace); + /// } + /// ``` + /// + /// If this is [null], Sentry logger is completely disabled (default). + String sentryDsn; + + String tunnel; + + /// A built-in retry mechanism for sending errors to sentry. + /// + /// This parameter defines the time to wait for, before retrying. + Duration sentryRetryDelay; + + /// Path of the directory where log files will be stored. + /// + /// If this is [null], file logging is completely disabled (default). + /// + /// If this is an empty string (['']), + /// then a 'logs' directory will be created in [getTemporaryDirectory()]. + /// + /// A non-empty string will be treated as an explicit path to a directory. + /// + /// The chosen directory can be accessed using [SuperLogging.logFile.parent]. + String logDirPath; + + /// The maximum number of log files inside [logDirPath]. + /// + /// One log file is created per day. + /// Older log files are deleted automatically. + int maxLogFiles; + + /// Whether to enable super logging features in debug mode. + /// + /// Sentry and file logging are typically not needed in debug mode, + /// where a complete logcat is available. + bool enableInDebugMode; + + /// If provided, super logging will invoke this function, and + /// any uncaught errors during its execution will be reported. + /// + /// Works by using [FlutterError.onError] and [runZoned]. + FutureOrVoidCallback body; + + /// The date format for storing log files. + /// + /// `DateFormat('y-M-d')` by default. + DateFormat dateFmt; + + String prefix; + + LogConfig({ + this.sentryDsn, + this.tunnel, + this.sentryRetryDelay = const Duration(seconds: 30), + this.logDirPath, + this.maxLogFiles = 10, + this.enableInDebugMode = false, + this.body, + this.dateFmt, + this.prefix = "", + }) { + dateFmt ??= DateFormat("y-M-d"); + } +} + +class SuperLogging { + /// The logger for SuperLogging + static final $ = Logger('ente_logging'); + + /// The current super logging configuration + static LogConfig config; + + static Future main([LogConfig config]) async { + config ??= LogConfig(); + SuperLogging.config = config; + + WidgetsFlutterBinding.ensureInitialized(); + + appVersion ??= await getAppVersion(); + final isFDroidClient = await isFDroidBuild(); + if (isFDroidClient) { + config.sentryDsn = null; + config.tunnel = null; + } + + final enable = config.enableInDebugMode || kReleaseMode; + sentryIsEnabled = enable && config.sentryDsn != null && !isFDroidClient; + fileIsEnabled = enable && config.logDirPath != null; + + if (fileIsEnabled) { + await setupLogDir(); + } + if (sentryIsEnabled) { + setupSentry(); + } + + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen(onLogRecord); + + if (isFDroidClient) { + assert( + sentryIsEnabled == false, + "sentry dsn should be disabled for " + "f-droid config ${config.sentryDsn} & ${config.tunnel}", + ); + } + + if (!enable) { + $.info("detected debug mode; sentry & file logging disabled."); + } + if (fileIsEnabled) { + $.info("log file for today: $logFile with prefix ${config.prefix}"); + } + if (sentryIsEnabled) { + $.info("sentry uploader started"); + } + + if (config.body == null) return; + + if (enable && sentryIsEnabled) { + // await SentryFlutter.init( + // (options) { + // options.dsn = config.sentryDsn; + // options.httpClient = http.Client(); + // if (config.tunnel != null) { + // options.transport = + // TunneledTransport(Uri.parse(config.tunnel), options); + // } + // }, + // appRunner: () => config.body(), + // ); + } else { + await config.body(); + } + } + + static void setUserID(String userID) async { + if (config?.sentryDsn != null) { + // Sentry.configureScope((scope) => scope.user = SentryUser(id: userID)); + $.info("setting sentry user ID to: $userID"); + } + } + + static Future _sendErrorToSentry(Object error, StackTrace stack) async { + // try { + // await Sentry.captureException( + // error, + // stackTrace: stack, + // ); + // } catch (e) { + // $.info('Sending report to sentry.io failed: $e'); + // $.info('Original error: $error'); + // } + } + + static String _lastExtraLines = ''; + + static Future onLogRecord(LogRecord rec) async { + // log misc info if it changed + String extraLines = "app version: '$appVersion'\n"; + if (extraLines != _lastExtraLines) { + _lastExtraLines = extraLines; + } else { + extraLines = null; + } + + final str = config.prefix + " " + rec.toPrettyString(extraLines); + + // write to stdout + printLog(str); + + // push to log queue + if (fileIsEnabled) { + fileQueueEntries.add(str + '\n'); + if (fileQueueEntries.length == 1) { + flushQueue(); + } + } + + // add error to sentry queue + if (sentryIsEnabled && rec.error != null) { + _sendErrorToSentry(rec.error, null); + } + } + + static final Queue fileQueueEntries = Queue(); + static bool isFlushing = false; + + static void flushQueue() async { + if (isFlushing) { + return; + } + isFlushing = true; + final entry = fileQueueEntries.removeFirst(); + await logFile.writeAsString(entry, mode: FileMode.append, flush: true); + isFlushing = false; + if (fileQueueEntries.isNotEmpty) { + flushQueue(); + } + } + + // Logs on must be chunked or they get truncated otherwise + // See https://github.com/flutter/flutter/issues/22665 + static var logChunkSize = 800; + + static void printLog(String text) { + text.chunked(logChunkSize).forEach(print); + } + + /// A queue to be consumed by [setupSentry]. + static final sentryQueueControl = StreamController(); + + /// Whether sentry logging is currently enabled or not. + static bool sentryIsEnabled; + + static Future setupSentry() async { + await for (final error in sentryQueueControl.stream.asBroadcastStream()) { + // try { + // Sentry.captureException( + // error, + // ); + // } catch (e) { + // $.fine( + // "sentry upload failed; will retry after ${config.sentryRetryDelay}", + // ); + // doSentryRetry(error); + // } + } + } + + static void doSentryRetry(Error error) async { + await Future.delayed(config.sentryRetryDelay); + sentryQueueControl.add(error); + } + + /// The log file currently in use. + static File logFile; + + /// Whether file logging is currently enabled or not. + static bool fileIsEnabled; + + static Future setupLogDir() async { + var dirPath = config.logDirPath; + + // choose [logDir] + if (dirPath.isEmpty) { + final root = await getExternalStorageDirectory(); + dirPath = '${root.path}/logs'; + } + + // create [logDir] + final dir = Directory(dirPath); + await dir.create(recursive: true); + + final files = []; + final dates = {}; + + // collect all log files with valid names + await for (final file in dir.list()) { + try { + final date = config.dateFmt.parse(basename(file.path)); + dates[file as File] = date; + files.add(file); + } on FormatException {} + } + + // delete old log files, if [maxLogFiles] is exceeded. + if (files.length > config.maxLogFiles) { + // sort files based on ascending order of date (older first) + files.sort((a, b) => dates[a].compareTo(dates[b])); + + final extra = files.length - config.maxLogFiles; + final toDelete = files.sublist(0, extra); + + for (final file in toDelete) { + try { + $.fine( + "deleting log file ${file.path}", + ); + await file.delete(); + } catch (ignore) {} + } + } + + logFile = File("$dirPath/${config.dateFmt.format(DateTime.now())}.txt"); + } + + /// Current app version, obtained from package_info plugin. + /// + /// See: [getAppVersion] + static String appVersion; + + static Future getAppVersion() async { + final pkgInfo = await PackageInfo.fromPlatform(); + return "${pkgInfo.version}+${pkgInfo.buildNumber}"; + } + + // disable sentry on f-droid. We need to make it opt-in preference + static Future isFDroidBuild() async { + if (!Platform.isAndroid) { + return false; + } + final pkgName = (await PackageInfo.fromPlatform()).packageName; + return pkgName.startsWith("io.ente.photos.fdroid"); + } +} diff --git a/lib/core/network.dart b/lib/core/network.dart new file mode 100644 index 000000000..0ca1c17e1 --- /dev/null +++ b/lib/core/network.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:fk_user_agent/fk_user_agent.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:uuid/uuid.dart'; + +int kConnectTimeout = 15000; + +class Network { + late Dio _dio; + + Future init() async { + await FkUserAgent.init(); + final packageInfo = await PackageInfo.fromPlatform(); + _dio = Dio( + BaseOptions( + connectTimeout: kConnectTimeout, + headers: { + HttpHeaders.userAgentHeader: FkUserAgent.userAgent, + 'X-Client-Version': packageInfo.version, + 'X-Client-Package': packageInfo.packageName, + }, + ), + ); + _dio.interceptors.add(RequestIdInterceptor()); + } + + Network._privateConstructor(); + + static Network instance = Network._privateConstructor(); + + Dio getDio() => _dio; +} + +class RequestIdInterceptor extends Interceptor { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + // ignore: prefer_const_constructors + options.headers.putIfAbsent("x-request-id", () => Uuid().v4().toString()); + return super.onRequest(options, handler); + } +} diff --git a/lib/ente_theme_data.dart b/lib/ente_theme_data.dart new file mode 100644 index 000000000..679282463 --- /dev/null +++ b/lib/ente_theme_data.dart @@ -0,0 +1,413 @@ +import 'package:ente_auth/theme/colors.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:flutter/material.dart'; + +final lightThemeData = ThemeData( + fontFamily: 'Inter', + brightness: Brightness.light, + hintColor: const Color.fromRGBO(158, 158, 158, 1), + primaryColor: const Color.fromRGBO(255, 110, 64, 1), + primaryColorLight: const Color.fromRGBO(0, 0, 0, 0.541), + iconTheme: const IconThemeData(color: Colors.black), + primaryIconTheme: + const IconThemeData(color: Colors.red, opacity: 1.0, size: 50.0), + colorScheme: const ColorScheme.light( + primary: Colors.black, + secondary: Color.fromARGB(255, 163, 163, 163), + ), + accentColor: const Color.fromRGBO(0, 0, 0, 0.6), + outlinedButtonTheme: buildOutlinedButtonThemeData( + bgDisabled: const Color.fromRGBO(158, 158, 158, 1), + bgEnabled: const Color.fromRGBO(0, 0, 0, 1), + fgDisabled: const Color.fromRGBO(255, 255, 255, 1), + fgEnabled: const Color.fromRGBO(255, 255, 255, 1), + ), + elevatedButtonTheme: buildElevatedButtonThemeData( + onPrimary: const Color.fromRGBO(255, 255, 255, 1), + primary: const Color.fromRGBO(0, 0, 0, 1), + ), + toggleableActiveColor: const Color.fromRGBO(102, 187, 106, 1), + scaffoldBackgroundColor: const Color.fromRGBO(255, 255, 255, 1), + backgroundColor: const Color.fromRGBO(255, 255, 255, 1), + appBarTheme: const AppBarTheme().copyWith( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + iconTheme: const IconThemeData(color: Colors.black), + elevation: 0, + ), + //https://api.flutter.dev/flutter/material/TextTheme-class.html + textTheme: _buildTextTheme(const Color.fromRGBO(0, 0, 0, 1)), + primaryTextTheme: const TextTheme().copyWith( + bodyText2: const TextStyle(color: Colors.yellow), + bodyText1: const TextStyle(color: Colors.orange), + ), + cardColor: const Color.fromRGBO(250, 250, 250, 1.0), + dialogTheme: const DialogTheme().copyWith( + backgroundColor: const Color.fromRGBO(250, 250, 250, 1.0), // + titleTextStyle: const TextStyle( + color: Colors.black, + fontSize: 24, + fontWeight: FontWeight.w600, + ), + contentTextStyle: const TextStyle( + fontFamily: 'Inter-Medium', + color: Colors.black, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + inputDecorationTheme: const InputDecorationTheme().copyWith( + focusedBorder: const UnderlineInputBorder( + borderSide: BorderSide( + color: Color.fromARGB(255, 152, 77, 244), + ), + ), + ), + checkboxTheme: CheckboxThemeData( + side: const BorderSide( + color: Colors.black, + width: 2, + ), + fillColor: MaterialStateProperty.resolveWith((states) { + return states.contains(MaterialState.selected) + ? const Color.fromRGBO(0, 0, 0, 1) + : const Color.fromRGBO(255, 255, 255, 1); + }), + checkColor: MaterialStateProperty.resolveWith((states) { + return states.contains(MaterialState.selected) + ? const Color.fromRGBO(255, 255, 255, 1) + : const Color.fromRGBO(0, 0, 0, 1); + }), + ), +); + +final darkThemeData = ThemeData( + fontFamily: 'Inter', + brightness: Brightness.dark, + primaryColorLight: const Color.fromRGBO(255, 255, 255, 0.702), + iconTheme: const IconThemeData(color: Colors.white), + primaryIconTheme: + const IconThemeData(color: Colors.red, opacity: 1.0, size: 50.0), + hintColor: const Color.fromRGBO(158, 158, 158, 1), + colorScheme: const ColorScheme.dark(primary: Colors.white), + accentColor: const Color.fromRGBO(45, 194, 98, 0.2), + buttonTheme: const ButtonThemeData().copyWith( + buttonColor: const Color.fromRGBO(45, 194, 98, 1.0), + ), + textTheme: _buildTextTheme(const Color.fromRGBO(255, 255, 255, 1)), + toggleableActiveColor: const Color.fromRGBO(102, 187, 106, 1), + outlinedButtonTheme: buildOutlinedButtonThemeData( + bgDisabled: const Color.fromRGBO(158, 158, 158, 1), + bgEnabled: const Color.fromRGBO(255, 255, 255, 1), + fgDisabled: const Color.fromRGBO(255, 255, 255, 1), + fgEnabled: const Color.fromRGBO(0, 0, 0, 1), + ), + elevatedButtonTheme: buildElevatedButtonThemeData( + onPrimary: const Color.fromRGBO(0, 0, 0, 1), + primary: const Color.fromRGBO(255, 255, 255, 1), + ), + scaffoldBackgroundColor: const Color.fromRGBO(0, 0, 0, 1), + backgroundColor: const Color.fromRGBO(0, 0, 0, 1), + appBarTheme: const AppBarTheme().copyWith( + color: Colors.black, + elevation: 0, + ), + cardColor: const Color.fromRGBO(10, 15, 15, 1.0), + dialogTheme: const DialogTheme().copyWith( + backgroundColor: const Color.fromRGBO(15, 15, 15, 1.0), + titleTextStyle: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.w600, + ), + contentTextStyle: const TextStyle( + fontFamily: 'Inter-Medium', + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + inputDecorationTheme: const InputDecorationTheme().copyWith( + focusedBorder: const UnderlineInputBorder( + borderSide: BorderSide( + color: Color.fromARGB(255, 152, 77, 244), + ), + ), + ), + checkboxTheme: CheckboxThemeData( + side: const BorderSide( + color: Colors.grey, + width: 2, + ), + fillColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.selected)) { + return const Color.fromRGBO(158, 158, 158, 1); + } else { + return const Color.fromRGBO(0, 0, 0, 1); + } + }), + checkColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.selected)) { + return const Color.fromRGBO(0, 0, 0, 1); + } else { + return const Color.fromRGBO(158, 158, 158, 1); + } + }), + ), +); + +TextTheme _buildTextTheme(Color textColor) { + return const TextTheme().copyWith( + headline4: TextStyle( + color: textColor, + fontSize: 32, + fontWeight: FontWeight.w600, + fontFamily: 'Inter', + ), + headline5: TextStyle( + color: textColor, + fontSize: 24, + fontWeight: FontWeight.w600, + fontFamily: 'Inter', + ), + headline6: TextStyle( + color: textColor, + fontSize: 18, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + ), + subtitle1: TextStyle( + color: textColor, + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w500, + ), + subtitle2: TextStyle( + color: textColor, + fontFamily: 'Inter', + fontSize: 14, + fontWeight: FontWeight.w500, + ), + bodyText1: TextStyle( + fontFamily: 'Inter', + color: textColor, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + bodyText2: TextStyle( + fontFamily: 'Inter', + color: textColor, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + caption: TextStyle( + color: textColor.withOpacity(0.4), + fontSize: 10, + fontWeight: FontWeight.w500, + ), + overline: TextStyle( + fontFamily: 'Inter', + color: textColor, + fontSize: 14, + fontWeight: FontWeight.w500, + decoration: TextDecoration.underline, + ), + ); +} + +extension CustomColorScheme on ColorScheme { + Color get defaultBackgroundColor => + brightness == Brightness.light ? backgroundBaseLight : backgroundBaseDark; + + Color get inverseBackgroundColor => + brightness != Brightness.light ? backgroundBaseLight : backgroundBaseDark; + + Color get defaultTextColor => + brightness == Brightness.light ? textBaseLight : textBaseDark; + + Color get inverseTextColor => + brightness != Brightness.light ? textBaseLight : textBaseDark; + + Color get boxSelectColor => brightness == Brightness.light + ? const Color.fromRGBO(67, 186, 108, 1) + : const Color.fromRGBO(16, 32, 32, 1); + + Color get boxUnSelectColor => brightness == Brightness.light + ? const Color.fromRGBO(240, 240, 240, 1) + : const Color.fromRGBO(8, 18, 18, 0.4); + + Color get alternativeColor => const Color.fromARGB(255, 152, 77, 244); + + Color get dynamicFABBackgroundColor => brightness == Brightness.light + ? const Color.fromRGBO(0, 0, 0, 1) + : const Color.fromRGBO(48, 48, 48, 1); + + Color get dynamicFABTextColor => + const Color.fromRGBO(255, 255, 255, 1); //same for both themes + + // todo: use brightness == Brightness.light for changing color for dark/light theme + ButtonStyle? get optionalActionButtonStyle => buildElevatedButtonThemeData( + onPrimary: const Color(0xFF777777), + primary: const Color(0xFFF0F0F0), + elevation: 0, + ).style; + + Color get recoveryKeyBoxColor => brightness == Brightness.light + ? const Color.fromARGB(51, 150, 0, 220) + : const Color.fromARGB(255, 174, 56, 247); + + Color get frostyBlurBackdropFilterColor => brightness == Brightness.light + ? const Color.fromRGBO(238, 238, 238, 0.5) + : const Color.fromRGBO(48, 48, 48, 0.5); + + Color get iconColor => brightness == Brightness.light + ? const Color.fromRGBO(0, 0, 0, 1).withOpacity(0.75) + : const Color.fromRGBO(255, 255, 255, 1); + + Color get bgColorForQuestions => brightness == Brightness.light + ? const Color.fromRGBO(255, 255, 255, 1) + : const Color.fromRGBO(10, 15, 15, 1.0); + + Color get greenText => const Color.fromARGB(255, 40, 190, 113); + + Color get cupertinoPickerTopColor => brightness == Brightness.light + ? const Color.fromARGB(255, 238, 238, 238) + : const Color.fromRGBO(255, 255, 255, 1).withOpacity(0.1); + + Color get stepProgressUnselectedColor => brightness == Brightness.light + ? const Color.fromRGBO(196, 196, 196, 0.6) + : const Color.fromRGBO(255, 255, 255, 0.7); + + Color get gNavBackgroundColor => brightness == Brightness.light + ? const Color.fromRGBO(196, 196, 196, 0.6) + : const Color.fromRGBO(40, 40, 40, 0.6); + + Color get gNavBarActiveColor => brightness == Brightness.light + ? const Color.fromRGBO(255, 255, 255, 0.6) + : const Color.fromRGBO(255, 255, 255, 0.9); + + Color get gNavIconColor => brightness == Brightness.light + ? const Color.fromRGBO(0, 0, 0, 0.8) + : const Color.fromRGBO(255, 255, 255, 0.8); + + Color get gNavActiveIconColor => brightness == Brightness.light + ? const Color.fromRGBO(0, 0, 0, 0.8) + : const Color.fromRGBO(0, 0, 0, 0.8); + + Color get galleryThumbBackgroundColor => brightness == Brightness.light + ? const Color.fromRGBO(240, 240, 240, 1) + : const Color.fromRGBO(20, 20, 20, 1); + + Color get galleryThumbDrawColor => brightness == Brightness.light + ? const Color.fromRGBO(0, 0, 0, 1).withOpacity(0.8) + : const Color.fromRGBO(255, 255, 255, 1).withOpacity(0.5); + + Color get backupEnabledBgColor => brightness == Brightness.light + ? const Color.fromRGBO(230, 230, 230, 0.95) + : const Color.fromRGBO(10, 40, 40, 0.3); + + Color get dotsIndicatorActiveColor => brightness == Brightness.light + ? const Color.fromRGBO(0, 0, 0, 1).withOpacity(0.5) + : const Color.fromRGBO(255, 255, 255, 1).withOpacity(0.5); + + Color get dotsIndicatorInactiveColor => brightness == Brightness.light + ? const Color.fromRGBO(0, 0, 0, 1).withOpacity(0.12) + : const Color.fromRGBO(255, 255, 255, 1).withOpacity(0.12); + + Color get toastTextColor => brightness == Brightness.light + ? const Color.fromRGBO(255, 255, 255, 1) + : const Color.fromRGBO(0, 0, 0, 1); + + Color get toastBackgroundColor => brightness == Brightness.light + ? const Color.fromRGBO(24, 24, 24, 0.95) + : const Color.fromRGBO(255, 255, 255, 0.95); + + Color get subTextColor => brightness == Brightness.light + ? const Color.fromRGBO(180, 180, 180, 1) + : const Color.fromRGBO(100, 100, 100, 1); + + Color get themeSwitchInactiveIconColor => brightness == Brightness.light + ? const Color.fromRGBO(0, 0, 0, 1).withOpacity(0.5) + : const Color.fromRGBO(255, 255, 255, 1).withOpacity(0.5); + + Color get searchResultsColor => brightness == Brightness.light + ? const Color.fromRGBO(245, 245, 245, 1.0) + : const Color.fromRGBO(30, 30, 30, 1.0); + + Color get searchResultsCountTextColor => brightness == Brightness.light + ? const Color.fromRGBO(80, 80, 80, 1) + : const Color.fromRGBO(150, 150, 150, 1); + + Color get searchResultsBackgroundColor => brightness == Brightness.light + ? Colors.black.withOpacity(0.32) + : Colors.black.withOpacity(0.64); + + EnteTheme get enteTheme => + brightness == Brightness.light ? lightTheme : darkTheme; +} + +OutlinedButtonThemeData buildOutlinedButtonThemeData({ + required Color bgDisabled, + required Color bgEnabled, + required Color fgDisabled, + required Color fgEnabled, +}) { + return OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + padding: const EdgeInsets.fromLTRB(50, 16, 50, 16), + textStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontFamily: 'Inter-SemiBold', + fontSize: 18, + ), + ).copyWith( + backgroundColor: MaterialStateProperty.resolveWith( + (Set states) { + if (states.contains(MaterialState.disabled)) { + return bgDisabled; + } + return bgEnabled; + }, + ), + foregroundColor: MaterialStateProperty.resolveWith( + (Set states) { + if (states.contains(MaterialState.disabled)) { + return fgDisabled; + } + return fgEnabled; + }, + ), + alignment: Alignment.center, + ), + ); +} + +ElevatedButtonThemeData buildElevatedButtonThemeData({ + required Color onPrimary, // text button color + required Color primary, + double elevation = 2, // background color of button +}) { + return ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + elevation: elevation, + onPrimary: onPrimary, + primary: primary, + alignment: Alignment.center, + textStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontFamily: 'Inter-SemiBold', + fontSize: 18, + ), + padding: const EdgeInsets.symmetric(vertical: 18), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + ), + ); +} diff --git a/lib/events/account_configured_event.dart b/lib/events/account_configured_event.dart new file mode 100644 index 000000000..c8c7c55ed --- /dev/null +++ b/lib/events/account_configured_event.dart @@ -0,0 +1,3 @@ +import 'package:ente_auth/events/event.dart'; + +class AccountConfiguredEvent extends Event {} diff --git a/lib/events/codes_updated_event.dart b/lib/events/codes_updated_event.dart new file mode 100644 index 000000000..b678413e8 --- /dev/null +++ b/lib/events/codes_updated_event.dart @@ -0,0 +1,3 @@ +import 'package:ente_auth/events/event.dart'; + +class CodesUpdatedEvent extends Event {} diff --git a/lib/events/event.dart b/lib/events/event.dart new file mode 100644 index 000000000..bd2189293 --- /dev/null +++ b/lib/events/event.dart @@ -0,0 +1,3 @@ + + +class Event {} diff --git a/lib/events/notification_event.dart b/lib/events/notification_event.dart new file mode 100644 index 000000000..87fe1d906 --- /dev/null +++ b/lib/events/notification_event.dart @@ -0,0 +1,5 @@ +import 'package:ente_auth/events/event.dart'; + +// NotificationEvent event is used to re-fresh the UI to show latest notification +// (if any) +class NotificationEvent extends Event {} diff --git a/lib/events/opened_settings_event.dart b/lib/events/opened_settings_event.dart new file mode 100644 index 000000000..3aadeb122 --- /dev/null +++ b/lib/events/opened_settings_event.dart @@ -0,0 +1,3 @@ +import 'package:ente_auth/events/event.dart'; + +class OpenedSettingsEvent extends Event {} diff --git a/lib/events/signed_in_event.dart b/lib/events/signed_in_event.dart new file mode 100644 index 000000000..f0720fac9 --- /dev/null +++ b/lib/events/signed_in_event.dart @@ -0,0 +1,3 @@ +import 'package:ente_auth/events/event.dart'; + +class SignedInEvent extends Event {} diff --git a/lib/events/subscription_purchased_event.dart b/lib/events/subscription_purchased_event.dart new file mode 100644 index 000000000..e20b32f06 --- /dev/null +++ b/lib/events/subscription_purchased_event.dart @@ -0,0 +1,3 @@ +import 'package:ente_auth/events/event.dart'; + +class SubscriptionPurchasedEvent extends Event {} diff --git a/lib/events/two_factor_status_change_event.dart b/lib/events/two_factor_status_change_event.dart new file mode 100644 index 000000000..c963a198e --- /dev/null +++ b/lib/events/two_factor_status_change_event.dart @@ -0,0 +1,7 @@ +import 'package:ente_auth/events/event.dart'; + +class TwoFactorStatusChangeEvent extends Event { + final bool status; + + TwoFactorStatusChangeEvent(this.status); +} diff --git a/lib/events/user_details_changed_event.dart b/lib/events/user_details_changed_event.dart new file mode 100644 index 000000000..066999f47 --- /dev/null +++ b/lib/events/user_details_changed_event.dart @@ -0,0 +1,3 @@ +import 'package:ente_auth/events/event.dart'; + +class UserDetailsChangedEvent extends Event {} diff --git a/lib/events/user_logged_out_event.dart b/lib/events/user_logged_out_event.dart new file mode 100644 index 000000000..2908965a3 --- /dev/null +++ b/lib/events/user_logged_out_event.dart @@ -0,0 +1,3 @@ +import 'package:ente_auth/events/event.dart'; + +class UserLoggedOutEvent extends Event {} diff --git a/lib/gateway/authenticator.dart b/lib/gateway/authenticator.dart new file mode 100644 index 000000000..487f0a5b5 --- /dev/null +++ b/lib/gateway/authenticator.dart @@ -0,0 +1,126 @@ +import 'package:dio/dio.dart'; +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/errors.dart'; +import 'package:ente_auth/models/authenticator/auth_entity.dart'; +import 'package:ente_auth/models/authenticator/auth_key.dart'; + +class AuthenticatorGateway { + final Dio _dio; + final Configuration _config; + late String _basedEndpoint; + + AuthenticatorGateway(this._dio, this._config) { + _basedEndpoint = _config.getHttpEndpoint() + "/authenticator"; + } + + Future createKey(String encKey, String header) async { + await _dio.post( + _basedEndpoint + "/key", + data: { + "encryptedKey": encKey, + "header": header, + }, + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + } + + Future getKey() async { + try { + final response = await _dio.get( + _basedEndpoint + "/key", + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + return AuthKey.fromMap(response.data); + } on DioError catch (e) { + if (e.response != null && (e.response!.statusCode ?? 0) == 404) { + throw AuthenticatorKeyNotFound(); + } else { + rethrow; + } + } catch (e) { + rethrow; + } + } + + Future createEntity(String encryptedData, String header) async { + final response = await _dio.post( + _basedEndpoint + "/entity", + data: { + "encryptedData": encryptedData, + "header": header, + }, + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + return AuthEntity.fromMap(response.data); + } + + Future updateEntity( + String id, + String encryptedData, + String header, + ) async { + await _dio.put( + _basedEndpoint + "/entity", + data: { + "id": id, + "encryptedData": encryptedData, + "header": header, + }, + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + } + + Future deleteEntity( + String id, + ) async { + await _dio.delete( + _basedEndpoint + "/entity", + queryParameters: { + "id": id, + }, + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + } + + Future> getDiff(int sinceTime, {int limit = 500}) async { + final response = await _dio.get( + _basedEndpoint + "/entity/diff", + queryParameters: { + "sinceTime": sinceTime, + "limit": limit, + }, + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + final List authEntities = []; + final diff = response.data["diff"] as List; + for (var entry in diff) { + final AuthEntity entity = AuthEntity.fromMap(entry); + authEntities.add(entity); + } + return authEntities; + } +} diff --git a/lib/json/converter.dart b/lib/json/converter.dart new file mode 100644 index 000000000..9f45f8f8e --- /dev/null +++ b/lib/json/converter.dart @@ -0,0 +1,17 @@ +import 'dart:typed_data'; + +import "package:json_annotation/json_annotation.dart"; + +class Uint8ListConverter implements JsonConverter> { + const Uint8ListConverter(); + + @override + Uint8List fromJson(List? json) { + return json == null ? Uint8List(0) : Uint8List.fromList(json); + } + + @override + List toJson(Uint8List object) { + return object.toList(); + } +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb new file mode 100644 index 000000000..4a368f9b8 --- /dev/null +++ b/lib/l10n/arb/app_en.arb @@ -0,0 +1,23 @@ +{ + "@@locale": "en", + "counterAppBarTitle": "Counter", + "@counterAppBarTitle": { + "description": "Text shown in the AppBar of the Counter Page" + }, + "onBoardingBody": "Secure your 2FA codes", + "onBoardingGetStarted": "Get Started", + + "setupFirstAccount": "Setup your first account", + "importScanQrCode": "Scan a QR Code", + "importEnterSetupKey": "Enter a setup key", + "importAccountPageTitle": "Enter account details", + "accountNameHint": "Account name", + "accountKeyHint" : "Your key", + "accountKeyType": "Type of key", + "timeBasedKeyType": "Time based (TOTP)", + "counterBasedKeyType": "Counter based (HOTP)", + "importAddAction": "Add", + + "existingUser": "Existing User", + "newUser" : "New to ente" +} \ No newline at end of file diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb new file mode 100644 index 000000000..f1405f02e --- /dev/null +++ b/lib/l10n/arb/app_es.arb @@ -0,0 +1,7 @@ +{ + "@@locale": "es", + "counterAppBarTitle": "Contador", + "@counterAppBarTitle": { + "description": "Texto mostrado en la AppBar de la pรกgina del contador" + } +} \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart new file mode 100644 index 000000000..92a67a576 --- /dev/null +++ b/lib/l10n/l10n.dart @@ -0,0 +1,8 @@ +import "package:flutter/widgets.dart"; +import "package:flutter_gen/gen_l10n/app_localizations.dart"; + +export "package:flutter_gen/gen_l10n/app_localizations.dart"; + +extension AppLocalizationsX on BuildContext { + AppLocalizations get l10n => AppLocalizations.of(this); +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 000000000..e10722571 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,67 @@ +// @dart=2.9 +import "package:ente_auth/app/view/app.dart"; +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/logging/super_logging.dart'; +import 'package:ente_auth/core/network.dart'; +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/services/authenticator_service.dart'; +import 'package:ente_auth/services/billing_service.dart'; +import 'package:ente_auth/services/update_service.dart'; +import 'package:ente_auth/services/user_remote_flag_service.dart'; +import 'package:ente_auth/services/user_service.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/ui/tools/app_lock.dart'; +import 'package:ente_auth/ui/tools/lock_screen.dart'; +import 'package:ente_auth/utils/crypto_util.dart'; +import "package:flutter/material.dart"; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; + +final _logger = Logger("main"); + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await _runInForeground(); +} + +Future _runInForeground() async { + return await _runWithLogs(() async { + _logger.info("Starting app in foreground"); + await _init(false, via: 'mainMethod'); + runApp( + AppLock( + builder: (args) => const App(), + lockScreen: const LockScreen(), + enabled: Configuration.instance.shouldShowLockScreen(), + lightTheme: lightThemeData, + darkTheme: darkThemeData, + ), + ); + }); +} + +Future _runWithLogs(Function() function, {String prefix = ""}) async { + await SuperLogging.main( + LogConfig( + body: function, + logDirPath: (await getApplicationSupportDirectory()).path + "/logs", + maxLogFiles: 5, + enableInDebugMode: true, + prefix: prefix, + ), + ); +} + +Future _init(bool bool, {String via}) async { + InAppPurchaseConnection.enablePendingPurchases(); + CryptoUtil.init(); + await CodeStore.instance.init(); + await Configuration.instance.init(); + await Network.instance.init(); + await UserService.instance.init(); + await UserRemoteFlagService.instance.init(); + await UpdateService.instance.init(); + await AuthenticatorService.instance.init(); + await BillingService.instance.init(); +} diff --git a/lib/main_development.dart b/lib/main_development.dart new file mode 100644 index 000000000..539631434 --- /dev/null +++ b/lib/main_development.dart @@ -0,0 +1,6 @@ +import "package:ente_auth/app/app.dart"; +import "package:ente_auth/bootstrap.dart"; + +void main() { + bootstrap(() => const App()); +} diff --git a/lib/main_production.dart b/lib/main_production.dart new file mode 100644 index 000000000..539631434 --- /dev/null +++ b/lib/main_production.dart @@ -0,0 +1,6 @@ +import "package:ente_auth/app/app.dart"; +import "package:ente_auth/bootstrap.dart"; + +void main() { + bootstrap(() => const App()); +} diff --git a/lib/main_staging.dart b/lib/main_staging.dart new file mode 100644 index 000000000..539631434 --- /dev/null +++ b/lib/main_staging.dart @@ -0,0 +1,6 @@ +import "package:ente_auth/app/app.dart"; +import "package:ente_auth/bootstrap.dart"; + +void main() { + bootstrap(() => const App()); +} diff --git a/lib/models/authenticator/auth_entity.dart b/lib/models/authenticator/auth_entity.dart new file mode 100644 index 000000000..f5a99cb2d --- /dev/null +++ b/lib/models/authenticator/auth_entity.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +@immutable +class AuthEntity { + final String id; + // encryptedData will be null for diff items when item is deleted + final String? encryptedData; + final String? header; + final bool isDeleted; + final int createdAt; + final int updatedAt; + + AuthEntity( + this.id, + this.encryptedData, + this.header, + this.isDeleted, + this.createdAt, + this.updatedAt, + ); + + Map toMap() { + return { + 'id': id, + 'encryptedData': encryptedData, + 'header': header, + 'isDeleted': isDeleted, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + }; + } + + factory AuthEntity.fromMap(Map map) { + return AuthEntity( + map['id'], + map['encryptedData'], + map['header'], + map['isDeleted']!, + map['createdAt']!, + map['updatedAt']!, + ); + } + + String toJson() => json.encode(toMap()); + + factory AuthEntity.fromJson(String source) => + AuthEntity.fromMap(json.decode(source)); +} diff --git a/lib/models/authenticator/auth_key.dart b/lib/models/authenticator/auth_key.dart new file mode 100644 index 000000000..5b174e1d0 --- /dev/null +++ b/lib/models/authenticator/auth_key.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +@immutable +class AuthKey { + final int userID; + final String encryptedKey; + final String header; + final int createdAt; + final int updatedAt; + + AuthKey( + this.userID, + this.encryptedKey, + this.header, + this.createdAt, + this.updatedAt, + ); + + Map toMap() { + return { + 'userID': userID, + 'encryptedKey': encryptedKey, + 'header': header, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + }; + } + + factory AuthKey.fromMap(Map map) { + return AuthKey( + map['userID']?.toInt() ?? 0, + map['encryptedKey']!, + map['header']!, + map['createdAt']?.toInt() ?? 0, + map['updatedAt']?.toInt() ?? 0, + ); + } + + String toJson() => json.encode(toMap()); + + factory AuthKey.fromJson(String source) => + AuthKey.fromMap(json.decode(source)); +} diff --git a/lib/models/authenticator/local_auth_entity.dart b/lib/models/authenticator/local_auth_entity.dart new file mode 100644 index 000000000..2b1535e66 --- /dev/null +++ b/lib/models/authenticator/local_auth_entity.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +@immutable +class LocalAuthEntity { + final int generatedID; + // id can be null if a code has been scanned locally but it's yet to be + // synced with the remote server. + final String? id; + final String encryptedData; + final String header; + // createdAt and updateAt will be equal to local time of creation or updation + // till remote sync is completed. + final int createdAt; + final int updatedAt; + // shouldSync indicates that the entry was locally created or updated. The + // app should try to sync it to the server during next sync + final bool shouldSync; + + LocalAuthEntity( + this.generatedID, + this.id, + this.encryptedData, + this.header, + this.createdAt, + this.updatedAt, + this.shouldSync, + ); + + LocalAuthEntity copyWith({ + int? generatedID, + String? id, + String? encryptedData, + String? header, + int? createdAt, + int? updatedAt, + bool? shouldSync, + }) { + return LocalAuthEntity( + generatedID ?? this.generatedID, + id ?? this.id, + encryptedData ?? this.encryptedData, + header ?? this.header, + createdAt ?? this.createdAt, + updatedAt ?? this.updatedAt, + shouldSync ?? this.shouldSync, + ); + } + + Map toMap() { + return { + '_generatedID': generatedID, + 'id': id, + 'encryptedData': encryptedData, + 'header': header, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + // sqlite doesn't support bool type. map true to 1 and false to 0 + 'shouldSync': shouldSync ? 1 : 0, + }; + } + + factory LocalAuthEntity.fromMap(Map map) { + return LocalAuthEntity( + map['_generatedID']!, + map['id'], + map['encryptedData']!, + map['header']!, + map['createdAt']!, + map['updatedAt']!, + (map['shouldSync']! == 0) ? false : true, + ); + } + + String toJson() => json.encode(toMap()); + + factory LocalAuthEntity.fromJson(String source) => + LocalAuthEntity.fromMap(json.decode(source)); +} diff --git a/lib/models/billing_plan.dart b/lib/models/billing_plan.dart new file mode 100644 index 000000000..c875f0946 --- /dev/null +++ b/lib/models/billing_plan.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; + +class BillingPlans { + final List plans; + final FreePlan freePlan; + + BillingPlans({ + required this.plans, + required this.freePlan, + }); + + Map toMap() { + return { + 'plans': plans.map((x) => x.toMap()).toList(), + 'freePlan': freePlan.toMap(), + }; + } + + static fromMap(Map? map) { + if (map == null) return null; + + return BillingPlans( + plans: List.from( + map['plans']?.map((x) => BillingPlan.fromMap(x)), + ), + freePlan: FreePlan.fromMap(map['freePlan']), + ); + } + + factory BillingPlans.fromJson(String source) => + BillingPlans.fromMap(json.decode(source)); +} + +class FreePlan { + final int storage; + final int duration; + final String period; + FreePlan({ + required this.storage, + required this.duration, + required this.period, + }); + + Map toMap() { + return { + 'storage': storage, + 'duration': duration, + 'period': period, + }; + } + + static fromMap(Map? map) { + if (map == null) return null; + + return FreePlan( + storage: map['storage'], + duration: map['duration'], + period: map['period'], + ); + } +} + +class BillingPlan { + final String id; + final String androidID; + final String iosID; + final String stripeID; + final int storage; + final String price; + final String period; + + BillingPlan({ + required this.id, + required this.androidID, + required this.iosID, + required this.stripeID, + required this.storage, + required this.price, + required this.period, + }); + + Map toMap() { + return { + 'id': id, + 'androidID': androidID, + 'iosID': iosID, + 'stripeID': stripeID, + 'storage': storage, + 'price': price, + 'period': period, + }; + } + + static fromMap(Map? map) { + if (map == null) return null; + + return BillingPlan( + id: map['id'], + androidID: map['androidID'], + iosID: map['iosID'], + stripeID: map['stripeID'], + storage: map['storage'], + price: map['price'], + period: map['period'], + ); + } +} diff --git a/lib/models/code.dart b/lib/models/code.dart new file mode 100644 index 000000000..c21c4ac24 --- /dev/null +++ b/lib/models/code.dart @@ -0,0 +1,147 @@ +class Code { + static const defaultDigits = 6; + static const defaultPeriod = 30; + + int? id; + final String account; + final String issuer; + final int digits; + final int period; + final String secret; + final Algorithm algorithm; + final Type type; + final String rawData; + + Code( + this.account, + this.issuer, + this.digits, + this.period, + this.secret, + this.algorithm, + this.type, + this.rawData, { + this.id, + }); + + static Code fromAccountAndSecret(String account, String secret) { + return Code( + account, + "", + defaultDigits, + defaultPeriod, + secret, + Algorithm.sha1, + Type.totp, + "otpauth://totp/" + + account + + ":" + + account + + "?algorithm=SHA1&digits=6&issuer=" + + account + + "period=30&secret=" + + secret, + ); + } + + static Code fromRawData(String rawData) { + Uri uri = Uri.parse(rawData); + return Code( + _getAccount(uri), + _getIssuer(uri), + _getDigits(uri), + _getPeriod(uri), + uri.queryParameters['secret']!, + _getAlgorithm(uri), + _getType(uri), + rawData, + ); + } + + static String _getAccount(Uri uri) { + try { + return uri.path.split(':')[1]; + } catch (e) { + return ""; + } + } + + static String _getIssuer(Uri uri) { + try { + return uri.path.split(':')[0].substring(1); + } catch (e) { + return ""; + } + } + + static int _getDigits(Uri uri) { + try { + return int.parse(uri.queryParameters['digits']!); + } catch (e) { + return defaultDigits; + } + } + + static int _getPeriod(Uri uri) { + try { + return int.parse(uri.queryParameters['period']!); + } catch (e) { + return defaultPeriod; + } + } + + static Algorithm _getAlgorithm(Uri uri) { + try { + final algorithm = + uri.queryParameters['algorithm'].toString().toLowerCase(); + if (algorithm == "sha256") { + return Algorithm.sha256; + } else if (algorithm == "sha512") { + return Algorithm.sha512; + } + } catch (e) { + // nothing + } + return Algorithm.sha1; + } + + static Type _getType(Uri uri) { + return uri.host == "totp" ? Type.totp : Type.hotp; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is Code && + other.account == account && + other.issuer == issuer && + other.digits == digits && + other.period == period && + other.secret == secret && + other.type == type && + other.rawData == rawData; + } + + @override + int get hashCode { + return account.hashCode ^ + issuer.hashCode ^ + digits.hashCode ^ + period.hashCode ^ + secret.hashCode ^ + type.hashCode ^ + rawData.hashCode; + } +} + +enum Type { + totp, + hotp, +} + +enum Algorithm { + sha1, + sha256, + sha512, +} diff --git a/lib/models/delete_account.dart b/lib/models/delete_account.dart new file mode 100644 index 000000000..71ca04e9c --- /dev/null +++ b/lib/models/delete_account.dart @@ -0,0 +1,9 @@ +class DeleteChallengeResponse { + final bool allowDelete; + final String encryptedChallenge; + + DeleteChallengeResponse({ + required this.allowDelete, + required this.encryptedChallenge, + }); +} diff --git a/lib/models/derived_key_result.dart b/lib/models/derived_key_result.dart new file mode 100644 index 000000000..a071fb1f8 --- /dev/null +++ b/lib/models/derived_key_result.dart @@ -0,0 +1,9 @@ +import 'dart:typed_data'; + +class DerivedKeyResult { + final Uint8List key; + final int memLimit; + final int opsLimit; + + DerivedKeyResult(this.key, this.memLimit, this.opsLimit); +} diff --git a/lib/models/encryption_result.dart b/lib/models/encryption_result.dart new file mode 100644 index 000000000..9da16c573 --- /dev/null +++ b/lib/models/encryption_result.dart @@ -0,0 +1,15 @@ +import 'dart:typed_data'; + +class EncryptionResult { + final Uint8List? encryptedData; + final Uint8List? key; + final Uint8List? header; + final Uint8List? nonce; + + EncryptionResult({ + this.encryptedData, + this.key, + this.header, + this.nonce, + }); +} diff --git a/lib/models/key_attributes.dart b/lib/models/key_attributes.dart new file mode 100644 index 000000000..8b8ec3990 --- /dev/null +++ b/lib/models/key_attributes.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; + +class KeyAttributes { + final String kekSalt; + final String encryptedKey; + final String keyDecryptionNonce; + final String publicKey; + final String encryptedSecretKey; + final String secretKeyDecryptionNonce; + final int memLimit; + final int opsLimit; + final String masterKeyEncryptedWithRecoveryKey; + final String masterKeyDecryptionNonce; + final String recoveryKeyEncryptedWithMasterKey; + final String recoveryKeyDecryptionNonce; + + KeyAttributes( + this.kekSalt, + this.encryptedKey, + this.keyDecryptionNonce, + this.publicKey, + this.encryptedSecretKey, + this.secretKeyDecryptionNonce, + this.memLimit, + this.opsLimit, + this.masterKeyEncryptedWithRecoveryKey, + this.masterKeyDecryptionNonce, + this.recoveryKeyEncryptedWithMasterKey, + this.recoveryKeyDecryptionNonce, + ); + + Map toMap() { + return { + 'kekSalt': kekSalt, + 'encryptedKey': encryptedKey, + 'keyDecryptionNonce': keyDecryptionNonce, + 'publicKey': publicKey, + 'encryptedSecretKey': encryptedSecretKey, + 'secretKeyDecryptionNonce': secretKeyDecryptionNonce, + 'memLimit': memLimit, + 'opsLimit': opsLimit, + 'masterKeyEncryptedWithRecoveryKey': masterKeyEncryptedWithRecoveryKey, + 'masterKeyDecryptionNonce': masterKeyDecryptionNonce, + 'recoveryKeyEncryptedWithMasterKey': recoveryKeyEncryptedWithMasterKey, + 'recoveryKeyDecryptionNonce': recoveryKeyDecryptionNonce, + }; + } + + factory KeyAttributes.fromMap(Map map) { + return KeyAttributes( + map['kekSalt'], + map['encryptedKey'], + map['keyDecryptionNonce'], + map['publicKey'], + map['encryptedSecretKey'], + map['secretKeyDecryptionNonce'], + map['memLimit'], + map['opsLimit'], + map['masterKeyEncryptedWithRecoveryKey'], + map['masterKeyDecryptionNonce'], + map['recoveryKeyEncryptedWithMasterKey'], + map['recoveryKeyDecryptionNonce'], + ); + } + + String toJson() => json.encode(toMap()); + + factory KeyAttributes.fromJson(String source) => + KeyAttributes.fromMap(json.decode(source)); + + KeyAttributes copyWith({ + String? kekSalt, + String? encryptedKey, + String? keyDecryptionNonce, + String? publicKey, + String? encryptedSecretKey, + String? secretKeyDecryptionNonce, + int? memLimit, + int? opsLimit, + String? masterKeyEncryptedWithRecoveryKey, + String? masterKeyDecryptionNonce, + String? recoveryKeyEncryptedWithMasterKey, + String? recoveryKeyDecryptionNonce, + }) { + return KeyAttributes( + kekSalt ?? this.kekSalt, + encryptedKey ?? this.encryptedKey, + keyDecryptionNonce ?? this.keyDecryptionNonce, + publicKey ?? this.publicKey, + encryptedSecretKey ?? this.encryptedSecretKey, + secretKeyDecryptionNonce ?? this.secretKeyDecryptionNonce, + memLimit ?? this.memLimit, + opsLimit ?? this.opsLimit, + masterKeyEncryptedWithRecoveryKey ?? + this.masterKeyEncryptedWithRecoveryKey, + masterKeyDecryptionNonce ?? this.masterKeyDecryptionNonce, + recoveryKeyEncryptedWithMasterKey ?? + this.recoveryKeyEncryptedWithMasterKey, + recoveryKeyDecryptionNonce ?? this.recoveryKeyDecryptionNonce, + ); + } +} diff --git a/lib/models/key_gen_result.dart b/lib/models/key_gen_result.dart new file mode 100644 index 000000000..5a3abfbc5 --- /dev/null +++ b/lib/models/key_gen_result.dart @@ -0,0 +1,9 @@ +import 'package:ente_auth/models/key_attributes.dart'; +import 'package:ente_auth/models/private_key_attributes.dart'; + +class KeyGenResult { + final KeyAttributes keyAttributes; + final PrivateKeyAttributes privateKeyAttributes; + + KeyGenResult(this.keyAttributes, this.privateKeyAttributes); +} diff --git a/lib/models/magic_metadata.dart b/lib/models/magic_metadata.dart new file mode 100644 index 000000000..9edab547e --- /dev/null +++ b/lib/models/magic_metadata.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; + +const visibilityVisible = 0; +const visibilityArchive = 1; + +const magicKeyVisibility = 'visibility'; + +const pubMagicKeyEditedTime = 'editedTime'; +const pubMagicKeyEditedName = 'editedName'; + +class MagicMetadata { + // 0 -> visible + // 1 -> archived + // 2 -> hidden etc? + int visibility; + + MagicMetadata({required this.visibility}); + + factory MagicMetadata.fromEncodedJson(String encodedJson) => + MagicMetadata.fromJson(jsonDecode(encodedJson)); + + factory MagicMetadata.fromJson(dynamic json) => MagicMetadata.fromMap(json); + + static fromMap(Map? map) { + if (map == null) return null; + return MagicMetadata( + visibility: map[magicKeyVisibility] ?? visibilityVisible, + ); + } +} + +class PubMagicMetadata { + int? editedTime; + String? editedName; + + PubMagicMetadata({this.editedTime, this.editedName}); + + factory PubMagicMetadata.fromEncodedJson(String encodedJson) => + PubMagicMetadata.fromJson(jsonDecode(encodedJson)); + + factory PubMagicMetadata.fromJson(dynamic json) => + PubMagicMetadata.fromMap(json); + + static fromMap(Map? map) { + if (map == null) return null; + return PubMagicMetadata( + editedTime: map[pubMagicKeyEditedTime], + editedName: map[pubMagicKeyEditedName], + ); + } +} + +class CollectionMagicMetadata { + // 0 -> visible + // 1 -> archived + // 2 -> hidden etc? + int visibility; + + CollectionMagicMetadata({required this.visibility}); + + factory CollectionMagicMetadata.fromEncodedJson(String encodedJson) => + CollectionMagicMetadata.fromJson(jsonDecode(encodedJson)); + + factory CollectionMagicMetadata.fromJson(dynamic json) => + CollectionMagicMetadata.fromMap(json); + + static fromMap(Map? map) { + if (map == null) return null; + return CollectionMagicMetadata( + visibility: map[magicKeyVisibility] ?? visibilityVisible, + ); + } +} diff --git a/lib/models/private_key_attributes.dart b/lib/models/private_key_attributes.dart new file mode 100644 index 000000000..c92f017fc --- /dev/null +++ b/lib/models/private_key_attributes.dart @@ -0,0 +1,7 @@ +class PrivateKeyAttributes { + final String key; + final String recoveryKey; + final String secretKey; + + PrivateKeyAttributes(this.key, this.recoveryKey, this.secretKey); +} diff --git a/lib/models/public_key.dart b/lib/models/public_key.dart new file mode 100644 index 000000000..0d14a4a55 --- /dev/null +++ b/lib/models/public_key.dart @@ -0,0 +1,6 @@ +class PublicKey { + final String email; + final String publicKey; + + PublicKey(this.email, this.publicKey); +} diff --git a/lib/models/sessions.dart b/lib/models/sessions.dart new file mode 100644 index 000000000..56ba8a31a --- /dev/null +++ b/lib/models/sessions.dart @@ -0,0 +1,45 @@ +class Sessions { + final List sessions; + + Sessions( + this.sessions, + ); + + factory Sessions.fromMap(Map map) { + if (map["sessions"] == null) { + throw Exception('\'map["sessions"]\' must not be null'); + } + return Sessions( + List.from(map['sessions']?.map((x) => Session.fromMap(x))), + ); + } +} + +class Session { + final String token; + final int creationTime; + final String ip; + final String ua; + final String prettyUA; + final int lastUsedTime; + + Session( + this.token, + this.creationTime, + this.ip, + this.ua, + this.prettyUA, + this.lastUsedTime, + ); + + factory Session.fromMap(Map map) { + return Session( + map['token'], + map['creationTime'], + map['ip'], + map['ua'], + map['prettyUA'], + map['lastUsedTime'], + ); + } +} diff --git a/lib/models/set_keys_request.dart b/lib/models/set_keys_request.dart new file mode 100644 index 000000000..e782319f5 --- /dev/null +++ b/lib/models/set_keys_request.dart @@ -0,0 +1,25 @@ +class SetKeysRequest { + final String kekSalt; + final String encryptedKey; + final String keyDecryptionNonce; + final int memLimit; + final int opsLimit; + + SetKeysRequest({ + required this.kekSalt, + required this.encryptedKey, + required this.keyDecryptionNonce, + required this.memLimit, + required this.opsLimit, + }); + + Map toMap() { + return { + 'kekSalt': kekSalt, + 'encryptedKey': encryptedKey, + 'keyDecryptionNonce': keyDecryptionNonce, + 'memLimit': memLimit, + 'opsLimit': opsLimit, + }; + } +} diff --git a/lib/models/set_recovery_key_request.dart b/lib/models/set_recovery_key_request.dart new file mode 100644 index 000000000..1a03e4e36 --- /dev/null +++ b/lib/models/set_recovery_key_request.dart @@ -0,0 +1,22 @@ +class SetRecoveryKeyRequest { + final String masterKeyEncryptedWithRecoveryKey; + final String masterKeyDecryptionNonce; + final String recoveryKeyEncryptedWithMasterKey; + final String recoveryKeyDecryptionNonce; + + SetRecoveryKeyRequest( + this.masterKeyEncryptedWithRecoveryKey, + this.masterKeyDecryptionNonce, + this.recoveryKeyEncryptedWithMasterKey, + this.recoveryKeyDecryptionNonce, + ); + + Map toMap() { + return { + 'masterKeyEncryptedWithRecoveryKey': masterKeyEncryptedWithRecoveryKey, + 'masterKeyDecryptionNonce': masterKeyDecryptionNonce, + 'recoveryKeyEncryptedWithMasterKey': recoveryKeyEncryptedWithMasterKey, + 'recoveryKeyDecryptionNonce': recoveryKeyDecryptionNonce, + }; + } +} diff --git a/lib/models/subscription.dart b/lib/models/subscription.dart new file mode 100644 index 000000000..3804f9a2e --- /dev/null +++ b/lib/models/subscription.dart @@ -0,0 +1,65 @@ +const freeProductID = "free"; +const stripe = "stripe"; +const appStore = "appstore"; +const playStore = "playstore"; + +class Subscription { + final String productID; + final int storage; + final String originalTransactionID; + final String paymentProvider; + final int expiryTime; + final String price; + final String period; + final Attributes? attributes; + + Subscription({ + required this.productID, + required this.storage, + required this.originalTransactionID, + required this.paymentProvider, + required this.expiryTime, + required this.price, + required this.period, + this.attributes, + }); + + bool isValid() { + return expiryTime > DateTime.now().microsecondsSinceEpoch; + } + + bool isYearlyPlan() { + return 'year' == period; + } + + static fromMap(Map? map) { + if (map == null) return null; + return Subscription( + productID: map['productID'], + storage: map['storage'], + originalTransactionID: map['originalTransactionID'], + paymentProvider: map['paymentProvider'], + expiryTime: map['expiryTime'], + price: map['price'], + period: map['period'], + attributes: map["attributes"] != null + ? Attributes.fromJson(map["attributes"]) + : null, + ); + } +} + +class Attributes { + bool? isCancelled; + String? customerID; + + Attributes({ + this.isCancelled, + this.customerID, + }); + + Attributes.fromJson(dynamic json) { + isCancelled = json["isCancelled"]; + customerID = json["customerID"]; + } +} diff --git a/lib/models/upload_url.dart b/lib/models/upload_url.dart new file mode 100644 index 000000000..49aa3b6ad --- /dev/null +++ b/lib/models/upload_url.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; + +class UploadURL { + final String url; + final String objectKey; + + UploadURL(this.url, this.objectKey); + Map toMap() { + return { + 'url': url, + 'objectKey': objectKey, + }; + } + + factory UploadURL.fromMap(Map map) { + return UploadURL( + map['url'], + map['objectKey'], + ); + } + + String toJson() => json.encode(toMap()); + + factory UploadURL.fromJson(String source) => + UploadURL.fromMap(json.decode(source)); +} diff --git a/lib/models/user_details.dart b/lib/models/user_details.dart new file mode 100644 index 000000000..9533cb68a --- /dev/null +++ b/lib/models/user_details.dart @@ -0,0 +1,109 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:ente_auth/models/subscription.dart'; + +class UserDetails { + final String email; + final int usage; + final int fileCount; + final int sharedCollectionsCount; + final Subscription subscription; + final FamilyData? familyData; + + UserDetails( + this.email, + this.usage, + this.fileCount, + this.sharedCollectionsCount, + this.subscription, + this.familyData, + ); + + bool isPartOfFamily() { + return familyData?.members?.isNotEmpty ?? false; + } + + bool isFamilyAdmin() { + assert(isPartOfFamily(), "verify user is part of family before calling"); + final FamilyMember currentUserMember = familyData!.members! + .firstWhere((element) => element.email.trim() == email.trim()); + return currentUserMember.isAdmin; + } + + // getFamilyOrPersonalUsage will return total usage for family if user + // belong to family group. Otherwise, it will return storage consumed by + // current user + int getFamilyOrPersonalUsage() { + return isPartOfFamily() ? familyData!.getTotalUsage() : usage; + } + + int getFreeStorage() { + return max( + isPartOfFamily() + ? (familyData!.storage - familyData!.getTotalUsage()) + : (subscription.storage - (usage)), + 0, + ); + } + + int getTotalStorage() { + return isPartOfFamily() ? familyData!.storage : subscription.storage; + } + + factory UserDetails.fromMap(Map map) { + return UserDetails( + map['email'] as String, + map['usage'] as int, + (map['fileCount'] ?? 0) as int, + (map['sharedCollectionsCount'] ?? 0) as int, + Subscription.fromMap(map['subscription']), + FamilyData.fromMap(map['familyData']), + ); + } +} + +class FamilyMember { + final String email; + final int usage; + final String id; + final bool isAdmin; + + FamilyMember(this.email, this.usage, this.id, this.isAdmin); + + factory FamilyMember.fromMap(Map map) { + return FamilyMember( + (map['email'] ?? '') as String, + map['usage'] as int, + map['id'] as String, + map['isAdmin'] as bool, + ); + } +} + +class FamilyData { + final List? members; + + // Storage available based on the family plan + final int storage; + final int expiryTime; + + FamilyData(this.members, this.storage, this.expiryTime); + + int getTotalUsage() { + return members!.map((e) => e.usage).toList().sum; + } + + static fromMap(Map? map) { + if (map == null) return null; + assert(map['members'] != null && map['members'].length >= 0); + final members = List.from( + map['members'].map((x) => FamilyMember.fromMap(x)), + ); + return FamilyData( + members, + map['storage'] as int, + map['expiryTime'] as int, + ); + } +} diff --git a/lib/onboarding/view/onboarding_page.dart b/lib/onboarding/view/onboarding_page.dart new file mode 100644 index 000000000..8abcb6a35 --- /dev/null +++ b/lib/onboarding/view/onboarding_page.dart @@ -0,0 +1,161 @@ +// ignore_for_file: import_of_legacy_library_into_null_safe + +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/ente_theme_data.dart'; +import "package:ente_auth/l10n/l10n.dart"; +import 'package:ente_auth/ui/account/email_entry_page.dart'; +import 'package:ente_auth/ui/account/login_page.dart'; +import 'package:ente_auth/ui/account/password_entry_page.dart'; +import 'package:ente_auth/ui/account/password_reentry_page.dart'; +import 'package:ente_auth/ui/common/gradient_button.dart'; +import 'package:ente_auth/ui/home_page.dart'; +import "package:flutter/material.dart"; + +class OnboardingPage extends StatefulWidget { + const OnboardingPage({Key? key}) : super(key: key); + + @override + State createState() => _OnboardingPageState(); +} + +class _OnboardingPageState extends State { + @override + Widget build(BuildContext context) { + debugPrint("Building OnboardingPage"); + final l10n = context.l10n; + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: + const BoxConstraints.tightFor(height: 800, width: 450), + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 40.0, horizontal: 40), + child: Column( + children: [ + Column( + children: [ + Image.asset( + "assets/sheild-front-gradient.png", + width: 200, + height: 200, + ), + const SizedBox(height: 12), + const Text( + "ente", + style: TextStyle( + fontWeight: FontWeight.bold, + fontFamily: 'Montserrat', + fontSize: 42, + ), + ), + const SizedBox(height: 4), + Text( + "Authenticator", + style: Theme.of(context).textTheme.headline4, + ), + const SizedBox(height: 32), + Text( + l10n.onBoardingBody, + textAlign: TextAlign.center, + style: + Theme.of(context).textTheme.headline6!.copyWith( + color: Colors.white38, + ), + ), + ], + ), + const SizedBox(height: 100), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: GradientButton( + onTap: _navigateToSignUpPage, + text: l10n.newUser, + ), + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(20, 12, 20, 28), + child: Hero( + tag: "log_in", + child: ElevatedButton( + style: Theme.of(context) + .colorScheme + .optionalActionButtonStyle, + onPressed: _navigateToSignInPage, + child: Text( + l10n.existingUser, + style: const TextStyle( + color: Colors.black, // same for both themes + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + void _navigateToSignUpPage() { + Widget page; + if (Configuration.instance.getEncryptedToken() == null) { + page = const EmailEntryPage(); + } else { + // No key + if (Configuration.instance.getKeyAttributes() == null) { + // Never had a key + page = const PasswordEntryPage(); + } else if (Configuration.instance.getKey() == null) { + // Yet to decrypt the key + page = const PasswordReentryPage(); + } else { + // All is well, user just has not subscribed + page = const HomePage(); + } + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return page; + }, + ), + ); + } + + void _navigateToSignInPage() { + Widget page; + if (Configuration.instance.getEncryptedToken() == null) { + page = const LoginPage(); + } else { + // No key + if (Configuration.instance.getKeyAttributes() == null) { + // Never had a key + page = const PasswordEntryPage(); + } else if (Configuration.instance.getKey() == null) { + // Yet to decrypt the key + page = const PasswordReentryPage(); + } else { + // All is well, user just has not subscribed + // page = getSubscriptionPage(isOnBoarding: true); + page = const HomePage(); + } + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return page; + }, + ), + ); + } +} diff --git a/lib/onboarding/view/setup_enter_secret_key_page.dart b/lib/onboarding/view/setup_enter_secret_key_page.dart new file mode 100644 index 000000000..1d1e7627a --- /dev/null +++ b/lib/onboarding/view/setup_enter_secret_key_page.dart @@ -0,0 +1,112 @@ +import "package:ente_auth/l10n/l10n.dart"; +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/totp_util.dart'; +import 'package:flutter/foundation.dart'; +import "package:flutter/material.dart"; + +class SetupEnterSecretKeyPage extends StatefulWidget { + SetupEnterSecretKeyPage({Key? key}) : super(key: key); + + @override + State createState() => + _SetupEnterSecretKeyPageState(); +} + +class _SetupEnterSecretKeyPageState extends State { + final _accountController = TextEditingController(); + final _secretController = + TextEditingController(text: kDebugMode ? "JBSWY3DPEHPK3PXP" : ""); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar( + title: Text(l10n.importAccountPageTitle), + ), + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 40.0, horizontal: 40), + child: Column( + children: [ + TextFormField( + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return "Please enter some text"; + } + return null; + }, + decoration: InputDecoration( + hintText: l10n.accountNameHint, + ), + controller: _accountController, + autofocus: true, + ), + const SizedBox( + height: 20, + ), + TextFormField( + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return "Please enter some text"; + } + return null; + }, + decoration: InputDecoration( + hintText: l10n.accountKeyHint, + ), + controller: _secretController, + ), + const SizedBox( + height: 40, + ), + SizedBox( + width: 400, + child: OutlinedButton( + onPressed: () { + if (_accountController.text.trim().isEmpty || + _secretController.text.trim().isEmpty) { + _showIncorrectDetailsDialog(context); + return; + } + try { + final code = Code.fromAccountAndSecret( + _accountController.text.trim(), + _secretController.text.trim(), + ); + // Verify the validity of the code + getTotp(code); + Navigator.of(context).pop(code); + } catch (e) { + _showIncorrectDetailsDialog(context); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 4, + ), + child: Text(l10n.importAddAction), + ), + ), + ) + ], + ), + ), + ), + ), + ); + } + + void _showIncorrectDetailsDialog(BuildContext context) { + showErrorDialog( + context, + "Incorrect details", + "Please verify the entered details", + ); + } +} diff --git a/lib/services/authenticator_service.dart b/lib/services/authenticator_service.dart new file mode 100644 index 000000000..5419b5c4f --- /dev/null +++ b/lib/services/authenticator_service.dart @@ -0,0 +1,194 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/errors.dart'; +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/core/network.dart'; +import 'package:ente_auth/events/account_configured_event.dart'; +import 'package:ente_auth/gateway/authenticator.dart'; +import 'package:ente_auth/models/authenticator/auth_entity.dart'; +import 'package:ente_auth/models/authenticator/auth_key.dart'; +import 'package:ente_auth/models/authenticator/local_auth_entity.dart'; +import 'package:ente_auth/store/authenticator_db.dart'; +import 'package:ente_auth/utils/crypto_util.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_sodium/flutter_sodium.dart'; +import 'package:logging/logging.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AuthenticatorService { + final _logger = Logger((AuthenticatorService).toString()); + final _config = Configuration.instance; + late SharedPreferences _prefs; + late AuthenticatorGateway _gateway; + late AuthenticatorDB _db; + final String _lastEntitySyncTime = "lastEntitySyncTime"; + + AuthenticatorService._privateConstructor(); + + static final AuthenticatorService instance = + AuthenticatorService._privateConstructor(); + + Future init() async { + _prefs = await SharedPreferences.getInstance(); + _db = AuthenticatorDB.instance; + _gateway = AuthenticatorGateway(Network.instance.getDio(), _config); + if (Configuration.instance.hasConfiguredAccount()) { + unawaited(sync()); + } + Bus.instance.on().listen((event) { + unawaited(sync()); + }); + } + + Future> getAllIDtoStringMap() async { + final List result = await _db.getAll(); + final Map entries = {}; + if (result.isEmpty) { + return entries; + } + final key = await getOrCreateAuthDataKey(); + for (LocalAuthEntity e in result) { + final decryptedValue = await CryptoUtil.decryptChaCha( + Sodium.base642bin(e.encryptedData), + key, + Sodium.base642bin(e.header), + ); + entries[e.generatedID] = utf8.decode(decryptedValue); + } + return entries; + } + + Future addEntry(String plainText) async { + var key = await getOrCreateAuthDataKey(); + final encryptedKeyData = await CryptoUtil.encryptChaCha( + utf8.encode(plainText) as Uint8List, + key, + ); + String encryptedData = Sodium.bin2base64(encryptedKeyData.encryptedData!); + String header = Sodium.bin2base64(encryptedKeyData.header!); + final insertedID = await _db.insert(encryptedData, header); + unawaited(sync()); + return insertedID; + } + + Future updateEntry(int generatedID, String plainText) async { + var key = await getOrCreateAuthDataKey(); + final encryptedKeyData = await CryptoUtil.encryptChaCha( + utf8.encode(plainText) as Uint8List, + key, + ); + String encryptedData = Sodium.bin2base64(encryptedKeyData.encryptedData!); + String header = Sodium.bin2base64(encryptedKeyData.header!); + final int affectedRows = + await _db.updateEntry(generatedID, encryptedData, header); + assert( + affectedRows == 1, + "updateEntry should have updated exactly one row", + ); + unawaited(sync()); + } + + Future deleteEntry(int genID) async { + LocalAuthEntity? result = await _db.getEntryByID(genID); + if (result == null) { + _logger.info("No entry found for given id"); + return; + } + if (result.id != null) { + await _gateway.deleteEntity(result.id!); + } + await _db.deleteByIDs(generatedIDs: [genID]); + } + + Future sync() async { + try { + _logger.info("Sync"); + await _remoteToLocalSync(); + await _localToRemoteSync(); + } catch (e) { + _logger.severe("Failed to sync with remote", e); + } + } + + Future _remoteToLocalSync() async { + _logger.info('Initiating remote to local sync'); + final int lastSyncTime = _prefs.getInt(_lastEntitySyncTime) ?? 0; + const int fetchLimit = 500; + final List result = + await _gateway.getDiff(lastSyncTime, limit: fetchLimit); + if (result.isEmpty) { + _logger.info('remote fetch completed'); + return; + } + final maxSyncTime = result.map((e) => e.updatedAt).reduce(max); + List deletedIDs = + result.where((element) => element.isDeleted).map((e) => e.id).toList(); + result.removeWhere((element) => element.isDeleted); + await _db.insertOrReplace(result); + if (deletedIDs.isNotEmpty) { + await _db.deleteByIDs(ids: deletedIDs); + } + _prefs.setInt(_lastEntitySyncTime, maxSyncTime); + if (result.length == fetchLimit) { + await _remoteToLocalSync(); + } + } + + Future _localToRemoteSync() async { + _logger.info('Initiating local to remote sync'); + final List result = await _db.getAll(); + final List pendingUpdate = result + .where((element) => element.shouldSync || element.id == null) + .toList(); + for (LocalAuthEntity entity in pendingUpdate) { + if (entity.id == null) { + _logger.info("Adding new entry"); + final authEntity = + await _gateway.createEntity(entity.encryptedData, entity.header); + entity.copyWith(id: authEntity.id, shouldSync: false); + await _db.updateLocalEntity(entity); + } else { + _logger.info("Updating entry"); + await _gateway.updateEntity( + entity.id!, + entity.encryptedData, + entity.header, + ); + entity.copyWith(shouldSync: false); + await _db.updateLocalEntity(entity); + } + } + } + + Future getOrCreateAuthDataKey() async { + if (_config.getAuthSecretKey() != null) { + return _config.getAuthSecretKey()!; + } + try { + final AuthKey response = await _gateway.getKey(); + final authKey = CryptoUtil.decryptSync( + Sodium.base642bin(response.encryptedKey), + _config.getKey(), + Sodium.base642bin(response.header), + ); + await _config.setAuthSecretKey(Sodium.bin2base64(authKey)); + return authKey; + } on AuthenticatorKeyNotFound catch (e) { + _logger.info("AuthenticatorKeyNotFound generating key ${e.stackTrace}"); + final key = CryptoUtil.generateKey(); + final encryptedKeyData = CryptoUtil.encryptSync(key, _config.getKey()!); + await _gateway.createKey( + Sodium.bin2base64(encryptedKeyData.encryptedData!), + Sodium.bin2base64(encryptedKeyData.nonce!), + ); + await _config.setAuthSecretKey(Sodium.bin2base64(key)); + return key; + } catch (e, s) { + _logger.severe("Failed to getOrCreateAuthDataKey", e, s); + rethrow; + } + } +} diff --git a/lib/services/billing_service.dart b/lib/services/billing_service.dart new file mode 100644 index 000000000..46636d2bb --- /dev/null +++ b/lib/services/billing_service.dart @@ -0,0 +1,209 @@ +// @dart=2.9 + +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/errors.dart'; +import 'package:ente_auth/core/network.dart'; +import 'package:ente_auth/models/billing_plan.dart'; +import 'package:ente_auth/models/subscription.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:logging/logging.dart'; + +const kWebPaymentRedirectUrl = "https://payments.ente.io/frameRedirect"; +const kWebPaymentBaseEndpoint = String.fromEnvironment( + "web-payment", + defaultValue: "https://payments.ente.io", +); + +const kFamilyPlanManagementUrl = String.fromEnvironment( + "web-family", + defaultValue: "https://family.ente.io", +); + +class BillingService { + BillingService._privateConstructor(); + + static final BillingService instance = BillingService._privateConstructor(); + + final _logger = Logger("BillingService"); + final _dio = Network.instance.getDio(); + final _config = Configuration.instance; + + bool _isOnSubscriptionPage = false; + + Future _future; + + Future init() async { + if (Platform.isAndroid || Platform.isIOS) { + InAppPurchaseConnection.enablePendingPurchases(); + // if (Platform.isIOS && kDebugMode) { + // await FlutterInappPurchase.instance.initConnection; + // FlutterInappPurchase.instance.clearTransactionIOS(); + // } + InAppPurchaseConnection.instance.purchaseUpdatedStream + .listen((purchases) { + if (_isOnSubscriptionPage) { + return; + } + for (final purchase in purchases) { + if (purchase.status == PurchaseStatus.purchased) { + verifySubscription( + purchase.productID, + purchase.verificationData.serverVerificationData, + ).then((response) { + if (response != null) { + InAppPurchaseConnection.instance.completePurchase(purchase); + } + }); + } else if (Platform.isIOS && purchase.pendingCompletePurchase) { + InAppPurchaseConnection.instance.completePurchase(purchase); + } + } + }); + } + } + + void clearCache() { + _future = null; + } + + Future getBillingPlans() { + _future ??= (_config.getToken() == null + ? _fetchPublicBillingPlans() + : _fetchPrivateBillingPlans()) + .then((response) { + return BillingPlans.fromMap(response.data); + }); + return _future; + } + + Future> _fetchPrivateBillingPlans() { + return _dio.get( + _config.getHttpEndpoint() + "/billing/user-plans/", + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + } + + Future> _fetchPublicBillingPlans() { + return _dio.get(_config.getHttpEndpoint() + "/billing/plans/v2"); + } + + Future verifySubscription( + final productID, + final verificationData, { + final paymentProvider, + }) async { + try { + final response = await _dio.post( + _config.getHttpEndpoint() + "/billing/verify-subscription", + data: { + "paymentProvider": paymentProvider ?? + (Platform.isAndroid ? "playstore" : "appstore"), + "productID": productID, + "verificationData": verificationData, + }, + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + return Subscription.fromMap(response.data["subscription"]); + } on DioError catch (e) { + if (e.response != null && e.response.statusCode == 409) { + throw SubscriptionAlreadyClaimedError(); + } else { + rethrow; + } + } catch (e, s) { + _logger.severe(e, s); + rethrow; + } + } + + Future fetchSubscription() async { + try { + final response = await _dio.get( + _config.getHttpEndpoint() + "/billing/subscription", + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + final subscription = Subscription.fromMap(response.data["subscription"]); + return subscription; + } on DioError catch (e, s) { + _logger.severe(e, s); + rethrow; + } + } + + Future cancelStripeSubscription() async { + try { + final response = await _dio.post( + _config.getHttpEndpoint() + "/billing/stripe/cancel-subscription", + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + final subscription = Subscription.fromMap(response.data["subscription"]); + return subscription; + } on DioError catch (e, s) { + _logger.severe(e, s); + rethrow; + } + } + + Future activateStripeSubscription() async { + try { + final response = await _dio.post( + _config.getHttpEndpoint() + "/billing/stripe/activate-subscription", + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + final subscription = Subscription.fromMap(response.data["subscription"]); + return subscription; + } on DioError catch (e, s) { + _logger.severe(e, s); + rethrow; + } + } + + Future getStripeCustomerPortalUrl({ + String endpoint = kWebPaymentRedirectUrl, + }) async { + try { + final response = await _dio.get( + _config.getHttpEndpoint() + "/billing/stripe/customer-portal", + queryParameters: { + "redirectURL": kWebPaymentRedirectUrl, + }, + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + return response.data["url"]; + } on DioError catch (e, s) { + _logger.severe(e, s); + rethrow; + } + } + + void setIsOnSubscriptionPage(bool isOnSubscriptionPage) { + _isOnSubscriptionPage = isOnSubscriptionPage; + } +} diff --git a/lib/services/local_authentication_service.dart b/lib/services/local_authentication_service.dart new file mode 100644 index 000000000..2c0eff288 --- /dev/null +++ b/lib/services/local_authentication_service.dart @@ -0,0 +1,70 @@ +// @dart=2.9 + +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/ui/tools/app_lock.dart'; +import 'package:ente_auth/utils/auth_util.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/toast_util.dart'; +import 'package:flutter/material.dart'; +import 'package:local_auth/local_auth.dart'; + +class LocalAuthenticationService { + LocalAuthenticationService._privateConstructor(); + static final LocalAuthenticationService instance = + LocalAuthenticationService._privateConstructor(); + + Future requestLocalAuthentication( + BuildContext context, + String infoMessage, + ) async { + if (await _isLocalAuthSupportedOnDevice()) { + AppLock.of(context).setEnabled(false); + final result = await requestAuthentication(infoMessage); + AppLock.of(context).setEnabled( + Configuration.instance.shouldShowLockScreen(), + ); + if (!result) { + showToast(context, infoMessage); + return false; + } else { + return true; + } + } + return true; + } + + Future requestLocalAuthForLockScreen( + BuildContext context, + bool shouldEnableLockScreen, + String infoMessage, + String errorDialogContent, [ + String errorDialogTitle = "", + ]) async { + if (await LocalAuthentication().isDeviceSupported()) { + AppLock.of(context).disable(); + final result = await requestAuthentication( + infoMessage, + ); + if (result) { + AppLock.of(context).setEnabled(shouldEnableLockScreen); + await Configuration.instance + .setShouldShowLockScreen(shouldEnableLockScreen); + return true; + } else { + AppLock.of(context) + .setEnabled(Configuration.instance.shouldShowLockScreen()); + } + } else { + showErrorDialog( + context, + errorDialogTitle, + errorDialogContent, + ); + } + return false; + } + + Future _isLocalAuthSupportedOnDevice() async { + return await LocalAuthentication().isDeviceSupported(); + } +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart new file mode 100644 index 000000000..59e37fa96 --- /dev/null +++ b/lib/services/notification_service.dart @@ -0,0 +1,53 @@ +// import 'dart:io'; +// +// import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +// +// class NotificationService { +// static final NotificationService instance = +// NotificationService._privateConstructor(); +// +// NotificationService._privateConstructor(); +// final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin = +// FlutterLocalNotificationsPlugin(); +// +// Future init() async { +// if (!Platform.isAndroid) { +// return; +// } +// const AndroidInitializationSettings initializationSettingsAndroid = +// AndroidInitializationSettings('notification_icon'); +// const InitializationSettings initializationSettings = +// InitializationSettings( +// android: initializationSettingsAndroid, +// ); +// await _flutterLocalNotificationsPlugin.initialize( +// initializationSettings, +// onSelectNotification: selectNotification, +// ); +// } +// +// Future selectNotification(String? payload) async {} +// +// Future showNotification(String title, String message) async { +// if (!Platform.isAndroid) { +// return; +// } +// const AndroidNotificationDetails androidPlatformChannelSpecifics = +// AndroidNotificationDetails( +// 'io.ente.photos', +// 'ente', +// channelDescription: 'ente alerts', +// importance: Importance.max, +// priority: Priority.high, +// showWhen: false, +// ); +// const NotificationDetails platformChannelSpecifics = +// NotificationDetails(android: androidPlatformChannelSpecifics); +// await _flutterLocalNotificationsPlugin.show( +// 0, +// title, +// message, +// platformChannelSpecifics, +// ); +// } +// } diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart new file mode 100644 index 000000000..a61a91335 --- /dev/null +++ b/lib/services/update_service.dart @@ -0,0 +1,140 @@ +// @dart=2.9 + +import 'dart:io'; + +import 'package:ente_auth/core/network.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class UpdateService { + UpdateService._privateConstructor(); + + static final UpdateService instance = UpdateService._privateConstructor(); + static const kUpdateAvailableShownTimeKey = "update_available_shown_time_key"; + + LatestVersionInfo _latestVersion; + final _logger = Logger("UpdateService"); + PackageInfo _packageInfo; + SharedPreferences _prefs; + + Future init() async { + _packageInfo = await PackageInfo.fromPlatform(); + _prefs = await SharedPreferences.getInstance(); + } + + Future shouldUpdate() async { + if (!isIndependent()) { + return false; + } + try { + _latestVersion = await _getLatestVersionInfo(); + final currentVersionCode = int.parse(_packageInfo.buildNumber); + return currentVersionCode < _latestVersion.code; + } catch (e) { + _logger.severe(e); + return false; + } + } + + bool shouldForceUpdate(LatestVersionInfo info) { + if (!isIndependent()) { + return false; + } + try { + final currentVersionCode = int.parse(_packageInfo.buildNumber); + return currentVersionCode < info.lastSupportedVersionCode; + } catch (e) { + _logger.severe(e); + return false; + } + } + + LatestVersionInfo getLatestVersionInfo() { + return _latestVersion; + } + + // Future showUpdateNotification() async { + // if (!isIndependent()) { + // return; + // } + // final shouldUpdate = await this.shouldUpdate(); + // final lastNotificationShownTime = + // _prefs.getInt(kUpdateAvailableShownTimeKey) ?? 0; + // final now = DateTime.now().microsecondsSinceEpoch; + // final hasBeen3DaysSinceLastNotification = + // (now - lastNotificationShownTime) > (3 * microSecondsInDay); + // if (shouldUpdate && + // hasBeen3DaysSinceLastNotification && + // _latestVersion.shouldNotify) { + // NotificationService.instance.showNotification( + // "update available", + // "click to install our best version yet", + // ); + // await _prefs.setInt(kUpdateAvailableShownTimeKey, now); + // } else { + // _logger.info("Debouncing notification"); + // } + // } + + Future _getLatestVersionInfo() async { + final response = await Network.instance + .getDio() + .get("https://ente.io/release-info/independent.json"); + return LatestVersionInfo.fromMap(response.data["latestVersion"]); + } + + bool isIndependent() { + if (Platform.isIOS) { + return false; + } + if (!kDebugMode && + _packageInfo.packageName != "io.ente.authenticator.independent") { + return false; + } + return true; + } + + bool isIndependentFlavor() { + if (Platform.isIOS) { + return false; + } + return _packageInfo.packageName.startsWith("io.ente.authenticator.independent"); + } +} + +class LatestVersionInfo { + final String name; + final int code; + final List changelog; + final bool shouldForceUpdate; + final int lastSupportedVersionCode; + final String url; + final int size; + final bool shouldNotify; + + LatestVersionInfo( + this.name, + this.code, + this.changelog, + this.shouldForceUpdate, + this.lastSupportedVersionCode, + this.url, + this.size, + this.shouldNotify, + ); + + factory LatestVersionInfo.fromMap(Map map) { + return LatestVersionInfo( + map['name'], + map['code'], + List.from(map['changelog']), + map['shouldForceUpdate'], + map['lastSupportedVersionCode'] ?? 1, + map['url'], + map['size'], + map['shouldNotify'], + ); + } +} diff --git a/lib/services/user_remote_flag_service.dart b/lib/services/user_remote_flag_service.dart new file mode 100644 index 000000000..6961518aa --- /dev/null +++ b/lib/services/user_remote_flag_service.dart @@ -0,0 +1,141 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:dio/dio.dart'; +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/core/network.dart'; +import 'package:ente_auth/events/notification_event.dart'; +import 'package:ente_auth/services/user_service.dart'; +import 'package:logging/logging.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class UserRemoteFlagService { + final _dio = Network.instance.getDio(); + final _logger = Logger((UserRemoteFlagService).toString()); + final _config = Configuration.instance; + late SharedPreferences _prefs; + + UserRemoteFlagService._privateConstructor(); + + static final UserRemoteFlagService instance = + UserRemoteFlagService._privateConstructor(); + + static const String recoveryVerificationFlag = "recoveryKeyVerified"; + static const String needRecoveryKeyVerification = + "needRecoveryKeyVerification"; + + Future init() async { + _prefs = await SharedPreferences.getInstance(); + } + + bool shouldShowRecoveryVerification() { + if (!_prefs.containsKey(needRecoveryKeyVerification)) { + // fetch the status from remote + unawaited(_refreshRecoveryVerificationFlag()); + return false; + } else { + final bool shouldShow = _prefs.getBool(needRecoveryKeyVerification)!; + if (shouldShow) { + // refresh the status to check if user marked it as done on another device + unawaited(_refreshRecoveryVerificationFlag()); + } + return shouldShow; + } + } + + // markRecoveryVerificationAsDone is used to track if user has verified their + // recovery key in the past or not. This helps in avoid showing the same + // prompt to the user on re-install or signing into a different device + Future markRecoveryVerificationAsDone() async { + await _updateKeyValue(recoveryVerificationFlag, true.toString()); + await _prefs.setBool(needRecoveryKeyVerification, false); + } + + Future _refreshRecoveryVerificationFlag() async { + _logger.finest('refresh recovery key verification flag'); + final remoteStatusValue = + await _getValue(recoveryVerificationFlag, "false"); + final bool isNeedVerificationFlagSet = + _prefs.containsKey(needRecoveryKeyVerification); + if (remoteStatusValue.toLowerCase() == "true") { + await _prefs.setBool(needRecoveryKeyVerification, false); + // If the user verified on different device, then we should refresh + // the UI to dismiss the Notification. + if (isNeedVerificationFlagSet) { + Bus.instance.fire(NotificationEvent()); + } + } else if (!isNeedVerificationFlagSet) { + // Verification is not done yet as remoteStatus is false and local flag to + // show notification isn't set. Set the flag to true if any active + // session is older than 1 day. + final activeSessions = await UserService.instance.getActiveSessions(); + final int microSecondsInADay = const Duration(days: 1).inMicroseconds; + final bool anyActiveSessionOlderThanADay = + activeSessions.sessions.firstWhereOrNull( + (e) => + (e.creationTime + microSecondsInADay) < + DateTime.now().microsecondsSinceEpoch, + ) != + null; + if (anyActiveSessionOlderThanADay) { + await _prefs.setBool(needRecoveryKeyVerification, true); + Bus.instance.fire(NotificationEvent()); + } else { + // continue defaulting to no verification prompt + _logger.finest('No active session older than 1 day'); + } + } + } + + Future _getValue(String key, String? defaultValue) async { + try { + final Map queryParams = {"key": key}; + if (defaultValue != null) { + queryParams["defaultValue"] = defaultValue; + } + final response = await _dio.get( + _config.getHttpEndpoint() + "/remote-store", + queryParameters: queryParams, + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + if (response.statusCode != HttpStatus.ok) { + throw Exception("Unexpected status code ${response.statusCode}"); + } + return response.data["value"]; + } catch (e) { + _logger.info("Error while fetching bool status for $key", e); + rethrow; + } + } + + // _setBooleanFlag sets the corresponding flag on remote + // to mark recovery as completed + Future _updateKeyValue(String key, String value) async { + try { + final response = await _dio.post( + _config.getHttpEndpoint() + "/remote-store/update", + data: { + "key": key, + "value": value, + }, + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + if (response.statusCode != HttpStatus.ok) { + throw Exception("Unexpected state"); + } + } catch (e) { + _logger.warning("Failed to set flag for $key", e); + rethrow; + } + } +} diff --git a/lib/services/user_service.dart b/lib/services/user_service.dart new file mode 100644 index 000000000..ea605259e --- /dev/null +++ b/lib/services/user_service.dart @@ -0,0 +1,487 @@ +// @dart=2.9 + +import 'package:dio/dio.dart'; +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/core/network.dart'; +import 'package:ente_auth/events/user_details_changed_event.dart'; +import 'package:ente_auth/models/delete_account.dart'; +import 'package:ente_auth/models/key_attributes.dart'; +import 'package:ente_auth/models/key_gen_result.dart'; +import 'package:ente_auth/models/sessions.dart'; +import 'package:ente_auth/models/set_keys_request.dart'; +import 'package:ente_auth/models/set_recovery_key_request.dart'; +import 'package:ente_auth/models/user_details.dart'; +import 'package:ente_auth/ui/account/ott_verification_page.dart'; +import 'package:ente_auth/ui/account/password_entry_page.dart'; +import 'package:ente_auth/ui/account/password_reentry_page.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/toast_util.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; + +class UserService { + final _dio = Network.instance.getDio(); + final _logger = Logger("UserSerivce"); + final _config = Configuration.instance; + ValueNotifier emailValueNotifier; + + UserService._privateConstructor(); + static final UserService instance = UserService._privateConstructor(); + + Future init() async { + emailValueNotifier = + ValueNotifier(Configuration.instance.getEmail()); + } + + Future sendOtt( + BuildContext context, + String email, { + bool isChangeEmail = false, + bool isCreateAccountScreen = false, + }) async { + final dialog = createProgressDialog(context, "Please wait..."); + await dialog.show(); + try { + final response = await _dio.post( + _config.getHttpEndpoint() + "/users/ott", + data: { + "email": email, + "purpose": isChangeEmail ? "change" : "", + "client": "totp" + }, + ); + await dialog.hide(); + if (response != null && response.statusCode == 200) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return OTTVerificationPage( + email, + isChangeEmail: isChangeEmail, + isCreateAccountScreen: isCreateAccountScreen, + ); + }, + ), + ); + return; + } + showGenericErrorDialog(context); + } on DioError catch (e) { + await dialog.hide(); + _logger.info(e); + if (e.response != null && e.response.statusCode == 403) { + showErrorDialog(context, "Oops", "This email is already in use"); + } else { + showGenericErrorDialog(context); + } + } catch (e) { + await dialog.hide(); + _logger.severe(e); + showGenericErrorDialog(context); + } + } + + Future getUserDetailsV2({bool memoryCount = true}) async { + try { + final response = await _dio.get( + _config.getHttpEndpoint() + + "/users/details/v2?memoryCount=$memoryCount", + queryParameters: { + "memoryCount": memoryCount, + }, + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + return UserDetails.fromMap(response.data); + } on DioError catch (e) { + _logger.info(e); + rethrow; + } + } + + Future getActiveSessions() async { + try { + final response = await _dio.get( + _config.getHttpEndpoint() + "/users/sessions", + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + return Sessions.fromMap(response.data); + } on DioError catch (e) { + _logger.info(e); + rethrow; + } + } + + Future terminateSession(String token) async { + try { + await _dio.delete( + _config.getHttpEndpoint() + "/users/session", + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + queryParameters: { + "token": token, + }, + ); + } on DioError catch (e) { + _logger.info(e); + rethrow; + } + } + + Future leaveFamilyPlan() async { + try { + await _dio.delete( + _config.getHttpEndpoint() + "/family/leave", + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + } on DioError catch (e) { + _logger.warning('failed to leave family plan', e); + rethrow; + } + } + + Future logout(BuildContext context) async { + final dialog = createProgressDialog(context, "Logging out..."); + await dialog.show(); + try { + final response = await _dio.post( + _config.getHttpEndpoint() + "/users/logout", + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + if (response != null && response.statusCode == 200) { + await Configuration.instance.logout(); + await dialog.hide(); + Navigator.of(context).popUntil((route) => route.isFirst); + } else { + throw Exception("Log out action failed"); + } + } catch (e) { + _logger.severe(e); + await dialog.hide(); + showGenericErrorDialog(context); + } + } + + Future getDeleteChallenge( + BuildContext context, + ) async { + final dialog = createProgressDialog(context, "Please wait..."); + await dialog.show(); + try { + final response = await _dio.get( + _config.getHttpEndpoint() + "/users/delete-challenge", + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + if (response != null && response.statusCode == 200) { + // clear data + await dialog.hide(); + return DeleteChallengeResponse( + allowDelete: response.data["allowDelete"] as bool, + encryptedChallenge: response.data["encryptedChallenge"], + ); + } else { + throw Exception("delete action failed"); + } + } catch (e) { + _logger.severe(e); + await dialog.hide(); + await showGenericErrorDialog(context); + return null; + } + } + + Future deleteAccount( + BuildContext context, + String challengeResponse, + ) async { + final dialog = createProgressDialog(context, "Deleting account..."); + await dialog.show(); + try { + final response = await _dio.delete( + _config.getHttpEndpoint() + "/users/delete", + data: { + "challenge": challengeResponse, + }, + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + if (response != null && response.statusCode == 200) { + // clear data + await Configuration.instance.logout(); + await dialog.hide(); + showToast( + context, + "We have deleted your account and scheduled your uploaded data " + "for deletion.", + ); + Navigator.of(context).popUntil((route) => route.isFirst); + } else { + throw Exception("delete action failed"); + } + } catch (e) { + _logger.severe(e); + await dialog.hide(); + showGenericErrorDialog(context); + } + } + + Future verifyEmail(BuildContext context, String ott) async { + final dialog = createProgressDialog(context, "Please wait..."); + await dialog.show(); + try { + final response = await _dio.post( + _config.getHttpEndpoint() + "/users/verify-email", + data: { + "email": _config.getEmail(), + "ott": ott, + }, + ); + await dialog.hide(); + if (response != null && response.statusCode == 200) { + Widget page; + await _saveConfiguration(response); + if (Configuration.instance.getEncryptedToken() != null) { + page = const PasswordReentryPage(); + } else { + page = const PasswordEntryPage(); + } + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return page; + }, + ), + (route) => route.isFirst, + ); + } else { + // should never reach here + throw Exception("unexpected response during email verification"); + } + } on DioError catch (e) { + _logger.info(e); + await dialog.hide(); + if (e.response != null && e.response.statusCode == 410) { + await showErrorDialog( + context, + "Oops", + "Your verification code has expired", + ); + Navigator.of(context).pop(); + } else { + showErrorDialog( + context, + "Incorrect code", + "Sorry, the code you've entered is incorrect", + ); + } + } catch (e) { + await dialog.hide(); + _logger.severe(e); + showErrorDialog(context, "Oops", "Verification failed, please try again"); + } + } + + Future setEmail(String email) async { + await _config.setEmail(email); + emailValueNotifier.value = email ?? ""; + } + + Future changeEmail( + BuildContext context, + String email, + String ott, + ) async { + final dialog = createProgressDialog(context, "Please wait..."); + await dialog.show(); + try { + final response = await _dio.post( + _config.getHttpEndpoint() + "/users/change-email", + data: { + "email": email, + "ott": ott, + }, + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + await dialog.hide(); + if (response != null && response.statusCode == 200) { + showToast(context, "Email changed to " + email); + await setEmail(email); + Navigator.of(context).popUntil((route) => route.isFirst); + Bus.instance.fire(UserDetailsChangedEvent()); + return; + } + showErrorDialog(context, "Oops", "Verification failed, please try again"); + } on DioError catch (e) { + await dialog.hide(); + if (e.response != null && e.response.statusCode == 403) { + showErrorDialog(context, "Oops", "This email is already in use"); + } else { + showErrorDialog( + context, + "Incorrect code", + "Authentication failed, please try again", + ); + } + } catch (e) { + await dialog.hide(); + _logger.severe(e); + showErrorDialog(context, "Oops", "Verification failed, please try again"); + } + } + + Future setAttributes(KeyGenResult result) async { + try { + await _dio.put( + _config.getHttpEndpoint() + "/users/attributes", + data: { + "keyAttributes": result.keyAttributes.toMap(), + }, + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + await _config.setKey(result.privateKeyAttributes.key); + await _config.setSecretKey(result.privateKeyAttributes.secretKey); + await _config.setKeyAttributes(result.keyAttributes); + } catch (e) { + _logger.severe(e); + rethrow; + } + } + + Future updateKeyAttributes(KeyAttributes keyAttributes) async { + try { + final setKeyRequest = SetKeysRequest( + kekSalt: keyAttributes.kekSalt, + encryptedKey: keyAttributes.encryptedKey, + keyDecryptionNonce: keyAttributes.keyDecryptionNonce, + memLimit: keyAttributes.memLimit, + opsLimit: keyAttributes.opsLimit, + ); + await _dio.put( + _config.getHttpEndpoint() + "/users/keys", + data: setKeyRequest.toMap(), + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + await _config.setKeyAttributes(keyAttributes); + } catch (e) { + _logger.severe(e); + rethrow; + } + } + + Future setRecoveryKey(KeyAttributes keyAttributes) async { + try { + final setRecoveryKeyRequest = SetRecoveryKeyRequest( + keyAttributes.masterKeyEncryptedWithRecoveryKey, + keyAttributes.masterKeyDecryptionNonce, + keyAttributes.recoveryKeyEncryptedWithMasterKey, + keyAttributes.recoveryKeyDecryptionNonce, + ); + await _dio.put( + _config.getHttpEndpoint() + "/users/recovery-key", + data: setRecoveryKeyRequest.toMap(), + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + await _config.setKeyAttributes(keyAttributes); + } catch (e) { + _logger.severe(e); + rethrow; + } + } + + Future getPaymentToken() async { + try { + final response = await _dio.get( + "${_config.getHttpEndpoint()}/users/payment-token", + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + if (response != null && response.statusCode == 200) { + return response.data["paymentToken"]; + } else { + throw Exception("non 200 ok response"); + } + } catch (e) { + _logger.severe("Failed to get payment token", e); + return null; + } + } + + Future getFamiliesToken() async { + try { + final response = await _dio.get( + "${_config.getHttpEndpoint()}/users/families-token", + options: Options( + headers: { + "X-Auth-Token": _config.getToken(), + }, + ), + ); + if (response != null && response.statusCode == 200) { + return response.data["familiesToken"]; + } else { + throw Exception("non 200 ok response"); + } + } catch (e, s) { + _logger.severe("failed to fetch families token", e, s); + rethrow; + } + } + + Future _saveConfiguration(Response response) async { + await Configuration.instance.setUserID(response.data["id"]); + if (response.data["encryptedToken"] != null) { + await Configuration.instance + .setEncryptedToken(response.data["encryptedToken"]); + await Configuration.instance.setKeyAttributes( + KeyAttributes.fromMap(response.data["keyAttributes"]), + ); + } else { + await Configuration.instance.setToken(response.data["token"]); + } + } +} diff --git a/lib/store/authenticator_db.dart b/lib/store/authenticator_db.dart new file mode 100644 index 000000000..c6a098e43 --- /dev/null +++ b/lib/store/authenticator_db.dart @@ -0,0 +1,168 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:ente_auth/models/authenticator/auth_entity.dart'; +import 'package:ente_auth/models/authenticator/local_auth_entity.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; + +class AuthenticatorDB { + static const _databaseName = "ente.authenticator.db"; + static const _databaseVersion = 1; + + static const entityTable = 'entities'; + + AuthenticatorDB._privateConstructor(); + static final AuthenticatorDB instance = AuthenticatorDB._privateConstructor(); + + static Future? _dbFuture; + + Future get database async { + _dbFuture ??= _initDatabase(); + return _dbFuture!; + } + + Future _initDatabase() async { + final Directory documentsDirectory = + await getApplicationDocumentsDirectory(); + final String path = join(documentsDirectory.path, _databaseName); + debugPrint(path); + return await openDatabase( + path, + version: _databaseVersion, + onCreate: _onCreate, + ); + } + + Future _onCreate(Database db, int version) async { + await db.execute( + ''' + CREATE TABLE $entityTable ( + _generatedID INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + id TEXT, + encryptedData TEXT NOT NULL, + header TEXT NOT NULL, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + shouldSync INTERGER DEFAULT 0 + ); + CREATE UNIQUE INDEX entity_id on $entityTable (id); + ''', + ); + } + + Future insert(String encData, String header) async { + final db = await instance.database; + final int timeInMicroSeconds = DateTime.now().microsecondsSinceEpoch; + final insertedID = await db.insert( + entityTable, + { + "encryptedData": encData, + "header": header, + "shouldSync": 1, + "createdAt": timeInMicroSeconds, + "updatedAt": timeInMicroSeconds, + }, + ); + return insertedID; + } + + Future updateEntry( + int generatedID, + String encData, + String header, + ) async { + final db = await instance.database; + final int timeInMicroSeconds = DateTime.now().microsecondsSinceEpoch; + int affectedRows = await db.update( + entityTable, + { + "encryptedData": encData, + "header": header, + "shouldSync": 1, + "updatedAt": timeInMicroSeconds, + }, + where: '_generatedID = ?', + whereArgs: [generatedID], + ); + return affectedRows; + } + + Future insertOrReplace(List authEntities) async { + final db = await instance.database; + final batch = db.batch(); + for (AuthEntity authEntity in authEntities) { + final insertRow = authEntity.toMap(); + insertRow.remove('isDeleted'); + insertRow.putIfAbsent('shouldSync', () => 0); + batch.insert( + entityTable, + insertRow, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + } + + Future updateLocalEntity(LocalAuthEntity localAuthEntity) async { + final db = await instance.database; + await db.update( + entityTable, + localAuthEntity.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future getEntryByID(int genID) async { + final db = await instance.database; + final rows = await db + .query(entityTable, where: '_generatedID = ?', whereArgs: [genID]); + final listOfAuthEntities = _convertRows(rows); + if (listOfAuthEntities.isEmpty) { + return null; + } else { + return listOfAuthEntities.first; + } + } + + Future> getAll() async { + final db = await instance.database; + final rows = await db.rawQuery("SELECT * from $entityTable"); + return _convertRows(rows); + } + +// deleteByID will prefer generated id if both ids are passed during deletion + Future deleteByIDs({List? generatedIDs, List? ids}) async { + final db = await instance.database; + final batch = db.batch(); + const whereGenID = '_generatedID = ?'; + const whereID = 'id = ?'; + if (generatedIDs != null) { + for (int genId in generatedIDs) { + batch.delete(entityTable, where: whereGenID, whereArgs: [genId]); + } + } + if (ids != null) { + for (String id in ids) { + batch.delete(entityTable, where: whereID, whereArgs: [id]); + } + } + final result = await batch.commit(); + debugPrint("Done"); + } + + Future clearTable() async { + final db = await instance.database; + await db.delete(entityTable); + } + + List _convertRows(List> rows) { + final keys = []; + for (final row in rows) { + keys.add(LocalAuthEntity.fromMap(row)); + } + return keys; + } +} diff --git a/lib/store/code_store.dart b/lib/store/code_store.dart new file mode 100644 index 000000000..570e78ecd --- /dev/null +++ b/lib/store/code_store.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; + +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/events/codes_updated_event.dart'; +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/services/authenticator_service.dart'; + +class CodeStore { + static final CodeStore instance = CodeStore._privateConstructor(); + + CodeStore._privateConstructor(); + + late AuthenticatorService _authenticatorService; + + Future init() async { + _authenticatorService = AuthenticatorService.instance; + } + + Future> getAllCodes() async { + final Map rawCodesMap = + await _authenticatorService.getAllIDtoStringMap(); + final List codes = []; + for (final entry in rawCodesMap.entries) { + final decodeJson = jsonDecode(entry.value); + final code = Code.fromRawData(decodeJson); + code.id = entry.key; + codes.add(code); + } + return codes; + } + + Future addCode(Code code) async { + final codes = await getAllCodes(); + code.id = await _authenticatorService.addEntry(jsonEncode(code.rawData)); + codes.add(code); + Bus.instance.fire(CodesUpdatedEvent()); + } + + Future removeCode(Code code) async { + await _authenticatorService.deleteEntry(code.id!); + Bus.instance.fire(CodesUpdatedEvent()); + } +} diff --git a/lib/store/user_store.dart b/lib/store/user_store.dart new file mode 100644 index 000000000..30409671b --- /dev/null +++ b/lib/store/user_store.dart @@ -0,0 +1,17 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class UserStore { + UserStore._privateConstructor(); + + late SharedPreferences _preferences; + + static final UserStore instance = UserStore._privateConstructor(); + static const endpoint = String.fromEnvironment( + "endpoint", + defaultValue: "https://api.ente.io", + ); + + Future init() async { + _preferences = await SharedPreferences.getInstance(); + } +} diff --git a/lib/theme/colors.dart b/lib/theme/colors.dart new file mode 100644 index 000000000..d00ac7d4b --- /dev/null +++ b/lib/theme/colors.dart @@ -0,0 +1,156 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +class EnteColorScheme { + // Background Colors + final Color backgroundBase; + final Color backgroundElevated; + final Color backgroundElevated2; + + // Backdrop Colors + final Color backdropBase; + final Color backdropBaseMute; + + // Text Colors + final Color textBase; + final Color textMuted; + final Color textFaint; + + // Fill Colors + final Color fillBase; + final Color fillMuted; + final Color fillFaint; + + // Stroke Colors + final Color strokeBase; + final Color strokeMuted; + final Color strokeFaint; + + // Fixed Colors + final Color primary700; + final Color primary500; + final Color primary400; + final Color primary300; + + final Color warning700; + final Color warning500; + final Color warning400; + final Color caution500; + + const EnteColorScheme( + this.backgroundBase, + this.backgroundElevated, + this.backgroundElevated2, + this.backdropBase, + this.backdropBaseMute, + this.textBase, + this.textMuted, + this.textFaint, + this.fillBase, + this.fillMuted, + this.fillFaint, + this.strokeBase, + this.strokeMuted, + this.strokeFaint, { + this.primary700 = _primary700, + this.primary500 = _primary500, + this.primary400 = _primary400, + this.primary300 = _primary300, + this.warning700 = _warning700, + this.warning500 = _warning500, + this.warning400 = _warning700, + this.caution500 = _caution500, + }); +} + +const EnteColorScheme lightScheme = EnteColorScheme( + backgroundBaseLight, + backgroundElevatedLight, + backgroundElevated2Light, + backdropBaseLight, + backdropBaseMuteLight, + textBaseLight, + textMutedLight, + textFaintLight, + fillBaseLight, + fillMutedLight, + fillFaintLight, + strokeBaseLight, + strokeMutedLight, + strokeFaintLight, +); + +const EnteColorScheme darkScheme = EnteColorScheme( + backgroundBaseDark, + backgroundElevatedDark, + backgroundElevated2Dark, + backdropBaseDark, + backdropBaseMuteDark, + textBaseDark, + textMutedDark, + textFaintDark, + fillBaseDark, + fillMutedDark, + fillFaintDark, + strokeBaseDark, + strokeMutedDark, + strokeFaintDark, +); + +// Background Colors +const Color backgroundBaseLight = Color.fromRGBO(255, 255, 255, 1); +const Color backgroundElevatedLight = Color.fromRGBO(255, 255, 255, 1); +const Color backgroundElevated2Light = Color.fromRGBO(251, 251, 251, 1); + +const Color backgroundBaseDark = Color.fromRGBO(0, 0, 0, 1); +const Color backgroundElevatedDark = Color.fromRGBO(27, 27, 27, 1); +const Color backgroundElevated2Dark = Color.fromRGBO(37, 37, 37, 1); + +// Backdrop Colors +const Color backdropBaseLight = Color.fromRGBO(255, 255, 255, 0.75); +const Color backdropBaseMuteLight = Color.fromRGBO(255, 255, 255, 0.30); + +const Color backdropBaseDark = Color.fromRGBO(0, 0, 0, 0.65); +const Color backdropBaseMuteDark = Color.fromRGBO(0, 0, 0, 0.20); + +// Text Colors +const Color textBaseLight = Color.fromRGBO(0, 0, 0, 1); +const Color textMutedLight = Color.fromRGBO(0, 0, 0, 0.6); +const Color textFaintLight = Color.fromRGBO(0, 0, 0, 0.5); + +const Color textBaseDark = Color.fromRGBO(255, 255, 255, 1); +const Color textMutedDark = Color.fromRGBO(255, 255, 255, 0.7); +const Color textFaintDark = Color.fromRGBO(255, 255, 255, 0.5); + +// Fill Colors +const Color fillBaseLight = Color.fromRGBO(0, 0, 0, 1); +const Color fillMutedLight = Color.fromRGBO(0, 0, 0, 0.12); +const Color fillFaintLight = Color.fromRGBO(0, 0, 0, 0.04); + +const Color fillBaseDark = Color.fromRGBO(255, 255, 255, 1); +const Color fillMutedDark = Color.fromRGBO(255, 255, 255, 0.16); +const Color fillFaintDark = Color.fromRGBO(255, 255, 255, 0.12); + +// Stroke Colors +const Color strokeBaseLight = Color.fromRGBO(0, 0, 0, 1); +const Color strokeMutedLight = Color.fromRGBO(0, 0, 0, 0.24); +const Color strokeFaintLight = Color.fromRGBO(0, 0, 0, 0.04); + +const Color strokeBaseDark = Color.fromRGBO(255, 255, 255, 1); +const Color strokeMutedDark = Color.fromRGBO(255, 255, 255, 0.24); +const Color strokeFaintDark = Color.fromRGBO(255, 255, 255, 0.16); + +// Fixed Colors + +const Color _primary700 = Color.fromARGB(255, 164, 0, 182); +const Color _primary500 = Color.fromARGB(255, 204, 10, 101); +const Color _primary400 = Color.fromARGB(255, 122, 41, 193); +const Color _primary300 = Color.fromARGB(255, 152, 77, 244); + +const Color _warning700 = Color.fromRGBO(234, 63, 63, 1); +const Color _warning500 = Color.fromRGBO(255, 101, 101, 1); +const Color warning500 = Color.fromRGBO(255, 101, 101, 1); +const Color _warning400 = Color.fromRGBO(255, 111, 111, 1); + +const Color _caution500 = Color.fromRGBO(255, 194, 71, 1); diff --git a/lib/theme/effects.dart b/lib/theme/effects.dart new file mode 100644 index 000000000..e13558517 --- /dev/null +++ b/lib/theme/effects.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +const blurBase = 96; +const blurMuted = 48; +const blurFaint = 24; + +List shadowFloatLight = const [ + BoxShadow(blurRadius: 10, color: Color.fromRGBO(0, 0, 0, 0.25)), +]; + +List shadowMenuLight = const [ + BoxShadow(blurRadius: 6, color: Color.fromRGBO(0, 0, 0, 0.16)), + BoxShadow( + blurRadius: 6, + color: Color.fromRGBO(0, 0, 0, 0.12), + offset: Offset(0, 3), + ), +]; + +List shadowButtonLight = const [ + BoxShadow( + blurRadius: 4, + color: Color.fromRGBO(0, 0, 0, 0.25), + offset: Offset(0, 4), + ), +]; + +List shadowFloatDark = const [ + BoxShadow( + blurRadius: 12, + color: Color.fromRGBO(0, 0, 0, 0.75), + offset: Offset(0, 2), + ), +]; + +List shadowMenuDark = const [ + BoxShadow(blurRadius: 6, color: Color.fromRGBO(0, 0, 0, 0.50)), + BoxShadow( + blurRadius: 6, + color: Color.fromRGBO(0, 0, 0, 0.25), + offset: Offset(0, 3), + ), +]; + +List shadowButtonDark = const [ + BoxShadow( + blurRadius: 4, + color: Color.fromRGBO(0, 0, 0, 0.75), + offset: Offset(0, 4), + ), +]; diff --git a/lib/theme/ente_theme.dart b/lib/theme/ente_theme.dart new file mode 100644 index 000000000..ab231d31a --- /dev/null +++ b/lib/theme/ente_theme.dart @@ -0,0 +1,45 @@ +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/theme/colors.dart'; +import 'package:ente_auth/theme/effects.dart'; +import 'package:ente_auth/theme/text_style.dart'; +import 'package:flutter/material.dart'; + +class EnteTheme { + final EnteTextTheme textTheme; + final EnteColorScheme colorScheme; + final List shadowFloat; + final List shadowMenu; + final List shadowButton; + + const EnteTheme( + this.textTheme, + this.colorScheme, { + required this.shadowFloat, + required this.shadowMenu, + required this.shadowButton, + }); +} + +EnteTheme lightTheme = EnteTheme( + lightTextTheme, + lightScheme, + shadowFloat: shadowFloatLight, + shadowMenu: shadowMenuLight, + shadowButton: shadowButtonLight, +); + +EnteTheme darkTheme = EnteTheme( + darkTextTheme, + darkScheme, + shadowFloat: shadowFloatDark, + shadowMenu: shadowMenuDark, + shadowButton: shadowButtonDark, +); + +EnteColorScheme getEnteColorScheme(BuildContext context) { + return Theme.of(context).colorScheme.enteTheme.colorScheme; +} + +EnteTextTheme getEnteTextTheme(BuildContext context) { + return Theme.of(context).colorScheme.enteTheme.textTheme; +} diff --git a/lib/theme/text_style.dart b/lib/theme/text_style.dart new file mode 100644 index 000000000..fbbc335d3 --- /dev/null +++ b/lib/theme/text_style.dart @@ -0,0 +1,117 @@ +import 'package:ente_auth/theme/colors.dart'; +import 'package:flutter/material.dart'; + +const FontWeight _regularWeight = FontWeight.w500; +const FontWeight _boldWeight = FontWeight.w600; +const String _fontFamily = 'Inter'; + +const TextStyle h1 = TextStyle( + fontSize: 48, + height: 48 / 28, + fontWeight: _regularWeight, + fontFamily: _fontFamily, +); +const TextStyle h2 = TextStyle( + fontSize: 32, + height: 39 / 32.0, + fontWeight: _regularWeight, + fontFamily: _fontFamily, +); +const TextStyle h3 = TextStyle( + fontSize: 24, + height: 29 / 24.0, + fontWeight: _regularWeight, + fontFamily: _fontFamily, +); +const TextStyle large = TextStyle( + fontSize: 18, + height: 22 / 18.0, + fontWeight: _regularWeight, + fontFamily: _fontFamily, +); +const TextStyle body = TextStyle( + fontSize: 16, + height: 19.4 / 16.0, + fontWeight: _regularWeight, + fontFamily: _fontFamily, +); +const TextStyle small = TextStyle( + fontSize: 14, + height: 17 / 14.0, + fontWeight: _regularWeight, + fontFamily: _fontFamily, +); +const TextStyle mini = TextStyle( + fontSize: 12, + height: 15 / 12.0, + fontWeight: _regularWeight, + fontFamily: _fontFamily, +); +const TextStyle tiny = TextStyle( + fontSize: 10, + height: 12 / 10.0, + fontWeight: _regularWeight, + fontFamily: _fontFamily, +); + +class EnteTextTheme { + final TextStyle h1; + final TextStyle h1Bold; + final TextStyle h2; + final TextStyle h2Bold; + final TextStyle h3; + final TextStyle h3Bold; + final TextStyle large; + final TextStyle largeBold; + final TextStyle body; + final TextStyle bodyBold; + final TextStyle small; + final TextStyle smallBold; + final TextStyle mini; + final TextStyle miniBold; + final TextStyle tiny; + final TextStyle tinyBold; + + const EnteTextTheme({ + required this.h1, + required this.h1Bold, + required this.h2, + required this.h2Bold, + required this.h3, + required this.h3Bold, + required this.large, + required this.largeBold, + required this.body, + required this.bodyBold, + required this.small, + required this.smallBold, + required this.mini, + required this.miniBold, + required this.tiny, + required this.tinyBold, + }); +} + +EnteTextTheme lightTextTheme = _buildEnteTextStyle(textBaseLight); +EnteTextTheme darkTextTheme = _buildEnteTextStyle(textBaseDark); + +EnteTextTheme _buildEnteTextStyle(Color color) { + return EnteTextTheme( + h1: h1.copyWith(color: color), + h1Bold: h1.copyWith(color: color, fontWeight: _boldWeight), + h2: h2.copyWith(color: color), + h2Bold: h2.copyWith(color: color, fontWeight: _boldWeight), + h3: h3.copyWith(color: color), + h3Bold: h3.copyWith(color: color, fontWeight: _boldWeight), + large: large.copyWith(color: color), + largeBold: large.copyWith(color: color, fontWeight: _boldWeight), + body: body.copyWith(color: color), + bodyBold: body.copyWith(color: color, fontWeight: _boldWeight), + small: small.copyWith(color: color), + smallBold: small.copyWith(color: color, fontWeight: _boldWeight), + mini: mini.copyWith(color: color), + miniBold: mini.copyWith(color: color, fontWeight: _boldWeight), + tiny: tiny.copyWith(color: color), + tinyBold: tiny.copyWith(color: color, fontWeight: _boldWeight), + ); +} diff --git a/lib/ui/account/change_email_dialog.dart b/lib/ui/account/change_email_dialog.dart new file mode 100644 index 000000000..20818d333 --- /dev/null +++ b/lib/ui/account/change_email_dialog.dart @@ -0,0 +1,82 @@ +// @dart=2.9 + +import 'package:ente_auth/services/user_service.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/email_util.dart'; +import 'package:flutter/material.dart'; + +class ChangeEmailDialog extends StatefulWidget { + const ChangeEmailDialog({Key key}) : super(key: key); + + @override + State createState() => _ChangeEmailDialogState(); +} + +class _ChangeEmailDialogState extends State { + String _email; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text("Enter your email address"), + content: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + decoration: const InputDecoration( + hintText: 'Email', + hintStyle: TextStyle( + color: Colors.white30, + ), + contentPadding: EdgeInsets.all(12), + ), + onChanged: (value) { + setState(() { + _email = value; + }); + }, + autocorrect: false, + keyboardType: TextInputType.emailAddress, + initialValue: _email, + autofocus: true, + ), + ], + ), + ), + actions: [ + TextButton( + child: const Text( + "Cancel", + style: TextStyle( + color: Colors.redAccent, + ), + ), + onPressed: () { + Navigator.pop(context); + }, + ), + TextButton( + child: const Text( + "Verify", + style: TextStyle( + color: Colors.green, + ), + ), + onPressed: () { + if (!isValidEmail(_email)) { + showErrorDialog( + context, + "Invalid email address", + "Please enter a valid email address.", + ); + return; + } + UserService.instance.sendOtt(context, _email, isChangeEmail: true); + }, + ), + ], + ); + } +} diff --git a/lib/ui/account/delete_account_page.dart b/lib/ui/account/delete_account_page.dart new file mode 100644 index 000000000..cf2d117d5 --- /dev/null +++ b/lib/ui/account/delete_account_page.dart @@ -0,0 +1,246 @@ +// @dart=2.9 + +import 'dart:convert'; + +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/models/delete_account.dart'; +import 'package:ente_auth/services/local_authentication_service.dart'; +import 'package:ente_auth/services/user_service.dart'; +import 'package:ente_auth/ui/common/dialogs.dart'; +import 'package:ente_auth/ui/common/gradient_button.dart'; +import 'package:ente_auth/utils/crypto_util.dart'; +import 'package:ente_auth/utils/email_util.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_sodium/flutter_sodium.dart'; + +class DeleteAccountPage extends StatelessWidget { + const DeleteAccountPage({ + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + elevation: 0, + title: const Text("Delete account"), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + color: Theme.of(context).iconTheme.color, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24), + child: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Image.asset( + 'assets/broken_heart.png', + width: 200, + ), + const SizedBox( + height: 24, + ), + Center( + child: Text( + "We'll be sorry to see you go. Are you facing some issue?", + style: Theme.of(context).textTheme.subtitle1, + ), + ), + const SizedBox( + height: 12, + ), + RichText( + // textAlign: TextAlign.center, + text: TextSpan( + children: const [ + TextSpan(text: "Please write to us at "), + TextSpan( + text: "feedback@ente.io", + style: TextStyle(color: Color.fromRGBO(29, 185, 84, 1)), + ), + TextSpan( + text: ", maybe there is a way we can help.", + ), + ], + style: Theme.of(context).textTheme.subtitle1, + ), + ), + const SizedBox( + height: 24, + ), + GradientButton( + text: "Yes, send feedback", + iconData: Icons.check, + onTap: () async { + await sendEmail( + context, + to: 'feedback@ente.io', + subject: '[Feedback]', + ); + }, + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + ), + InkWell( + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + side: const BorderSide( + color: Colors.redAccent, + ), + padding: const EdgeInsets.symmetric( + vertical: 18, + horizontal: 10, + ), + backgroundColor: Colors.white, + ), + label: const Text( + "No, delete account", + style: TextStyle( + color: Colors.redAccent, // same for both themes + ), + textAlign: TextAlign.center, + ), + onPressed: () async => {await _initiateDelete(context)}, + icon: const Icon( + Icons.no_accounts, + color: Colors.redAccent, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Future _initiateDelete(BuildContext context) async { + final deleteChallengeResponse = + await UserService.instance.getDeleteChallenge(context); + if (deleteChallengeResponse == null) { + return; + } + if (deleteChallengeResponse.allowDelete) { + await _confirmAndDelete(context, deleteChallengeResponse); + } else { + await _requestEmailForDeletion(context); + } + } + + Future _confirmAndDelete( + BuildContext context, + DeleteChallengeResponse response, + ) async { + final hasAuthenticated = + await LocalAuthenticationService.instance.requestLocalAuthentication( + context, + "Please authenticate to initiate account deletion", + ); + + if (hasAuthenticated) { + final choice = await showChoiceDialog( + context, + 'Are you sure you want to delete your account?', + 'Your uploaded data will be scheduled for deletion, and your account ' + 'will be permanently deleted. \n\nThis action is not reversible.', + firstAction: 'Cancel', + secondAction: 'Delete', + firstActionColor: Theme.of(context).colorScheme.onSurface, + secondActionColor: Colors.red, + ); + if (choice != DialogUserChoice.secondChoice) { + return; + } + final decryptChallenge = CryptoUtil.openSealSync( + Sodium.base642bin(response.encryptedChallenge), + Sodium.base642bin(Configuration.instance.getKeyAttributes().publicKey), + Configuration.instance.getSecretKey(), + ); + final challengeResponseStr = utf8.decode(decryptChallenge); + await UserService.instance.deleteAccount(context, challengeResponseStr); + } + } + + Future _requestEmailForDeletion(BuildContext context) async { + final AlertDialog alert = AlertDialog( + title: const Text( + "Delete account", + style: TextStyle( + color: Colors.red, + ), + ), + content: RichText( + text: TextSpan( + children: [ + const TextSpan( + text: "Please send an email to ", + ), + TextSpan( + text: "account-deletion@ente.io", + style: TextStyle( + color: Colors.orange[300], + ), + ), + const TextSpan( + text: + " from your registered email address.\n\nYour request will be processed within 72 hours.", + ), + ], + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + height: 1.5, + fontSize: 16, + ), + ), + ), + actions: [ + TextButton( + child: const Text( + "Send email", + style: TextStyle( + color: Colors.red, + ), + ), + onPressed: () async { + Navigator.of(context, rootNavigator: true).pop('dialog'); + await sendEmail( + context, + to: 'account-deletion@ente.io', + subject: '[Delete account]', + ); + }, + ), + TextButton( + child: Text( + "Ok", + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop('dialog'); + }, + ), + ], + ); + + showDialog( + context: context, + builder: (BuildContext context) { + return alert; + }, + ); + } +} diff --git a/lib/ui/account/email_entry_page.dart b/lib/ui/account/email_entry_page.dart new file mode 100644 index 000000000..d30feead3 --- /dev/null +++ b/lib/ui/account/email_entry_page.dart @@ -0,0 +1,635 @@ +// @dart=2.9 + +import 'dart:io'; + +import 'package:email_validator/email_validator.dart'; +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/models/billing_plan.dart'; +import 'package:ente_auth/services/billing_service.dart'; +import 'package:ente_auth/services/user_service.dart'; +import 'package:ente_auth/ui/common/dynamic_fab.dart'; +import 'package:ente_auth/ui/common/loading_widget.dart'; +import 'package:ente_auth/ui/common/web_page.dart'; +import 'package:ente_auth/utils/data_util.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:password_strength/password_strength.dart'; +import 'package:step_progress_indicator/step_progress_indicator.dart'; + +class EmailEntryPage extends StatefulWidget { + const EmailEntryPage({Key key}) : super(key: key); + + @override + State createState() => _EmailEntryPageState(); +} + +class _EmailEntryPageState extends State { + static const kMildPasswordStrengthThreshold = 0.4; + static const kStrongPasswordStrengthThreshold = 0.7; + + final _config = Configuration.instance; + final _passwordController1 = TextEditingController(); + final _passwordController2 = TextEditingController(); + final Color _validFieldValueColor = const Color.fromARGB(51, 157, 45, 194); + + String _email; + String _password; + String _cnfPassword = ''; + double _passwordStrength = 0.0; + bool _emailIsValid = false; + bool _hasAgreedToTOS = true; + bool _hasAgreedToE2E = false; + bool _password1Visible = false; + bool _password2Visible = false; + bool _passwordsMatch = false; + + final _password1FocusNode = FocusNode(); + final _password2FocusNode = FocusNode(); + bool _password1InFocus = false; + bool _password2InFocus = false; + bool _passwordIsValid = false; + + @override + void initState() { + _email = _config.getEmail(); + _password1FocusNode.addListener(() { + setState(() { + _password1InFocus = _password1FocusNode.hasFocus; + }); + }); + _password2FocusNode.addListener(() { + setState(() { + _password2InFocus = _password2FocusNode.hasFocus; + }); + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final isKeypadOpen = MediaQuery.of(context).viewInsets.bottom > 100; + + FloatingActionButtonLocation fabLocation() { + if (isKeypadOpen) { + return null; + } else { + return FloatingActionButtonLocation.centerFloat; + } + } + + final appBar = AppBar( + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + color: Theme.of(context).iconTheme.color, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Material( + type: MaterialType.transparency, + child: StepProgressIndicator( + totalSteps: 4, + currentStep: 1, + selectedColor: Theme.of(context).colorScheme.alternativeColor, + roundedEdges: const Radius.circular(10), + unselectedColor: + Theme.of(context).colorScheme.stepProgressUnselectedColor, + ), + ), + ); + return Scaffold( + resizeToAvoidBottomInset: isKeypadOpen, + appBar: appBar, + body: _getBody(), + floatingActionButton: DynamicFAB( + isKeypadOpen: isKeypadOpen, + isFormValid: _isFormValid(), + buttonText: 'Create account', + onPressedFunction: () { + _config.setVolatilePassword(_passwordController1.text); + UserService.instance.setEmail(_email); + UserService.instance + .sendOtt(context, _email, isCreateAccountScreen: true); + FocusScope.of(context).unfocus(); + }, + ), + floatingActionButtonLocation: fabLocation(), + floatingActionButtonAnimator: NoScalingAnimation(), + ); + } + + Widget _getBody() { + var passwordStrengthText = 'Weak'; + var passwordStrengthColor = Colors.redAccent; + if (_passwordStrength > kStrongPasswordStrengthThreshold) { + passwordStrengthText = 'Strong'; + passwordStrengthColor = Colors.greenAccent; + } else if (_passwordStrength > kMildPasswordStrengthThreshold) { + passwordStrengthText = 'Moderate'; + passwordStrengthColor = Colors.orangeAccent; + } + return Column( + children: [ + Expanded( + child: AutofillGroup( + child: ListView( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(vertical: 30, horizontal: 20), + child: Text( + 'Create new account', + style: Theme.of(context).textTheme.headline4, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), + child: TextFormField( + style: Theme.of(context).textTheme.subtitle1, + autofillHints: const [AutofillHints.email], + decoration: InputDecoration( + fillColor: _emailIsValid ? _validFieldValueColor : null, + filled: true, + hintText: 'Email', + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(6), + ), + suffixIcon: _emailIsValid + ? Icon( + Icons.check, + size: 20, + color: Theme.of(context) + .inputDecorationTheme + .focusedBorder + .borderSide + .color, + ) + : null, + ), + onChanged: (value) { + _email = value.trim(); + if (_emailIsValid != EmailValidator.validate(_email)) { + setState(() { + _emailIsValid = EmailValidator.validate(_email); + }); + } + }, + autocorrect: false, + keyboardType: TextInputType.emailAddress, + //initialValue: _email, + textInputAction: TextInputAction.next, + ), + ), + const Padding(padding: EdgeInsets.all(4)), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), + child: TextFormField( + keyboardType: TextInputType.text, + controller: _passwordController1, + obscureText: !_password1Visible, + enableSuggestions: true, + autofillHints: const [AutofillHints.newPassword], + decoration: InputDecoration( + fillColor: + _passwordIsValid ? _validFieldValueColor : null, + filled: true, + hintText: "Password", + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + suffixIcon: _password1InFocus + ? IconButton( + icon: Icon( + _password1Visible + ? Icons.visibility + : Icons.visibility_off, + color: Theme.of(context).iconTheme.color, + size: 20, + ), + onPressed: () { + setState(() { + _password1Visible = !_password1Visible; + }); + }, + ) + : _passwordIsValid + ? Icon( + Icons.check, + color: Theme.of(context) + .inputDecorationTheme + .focusedBorder + .borderSide + .color, + ) + : null, + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(6), + ), + ), + focusNode: _password1FocusNode, + onChanged: (password) { + if (password != _password) { + setState(() { + _password = password; + _passwordStrength = + estimatePasswordStrength(password); + _passwordIsValid = _passwordStrength >= + kMildPasswordStrengthThreshold; + _passwordsMatch = _password == _cnfPassword; + }); + } + }, + onEditingComplete: () { + _password1FocusNode.unfocus(); + _password2FocusNode.requestFocus(); + TextInput.finishAutofillContext(); + }, + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), + child: TextFormField( + keyboardType: TextInputType.visiblePassword, + controller: _passwordController2, + obscureText: !_password2Visible, + autofillHints: const [AutofillHints.newPassword], + onEditingComplete: () => TextInput.finishAutofillContext(), + decoration: InputDecoration( + fillColor: _passwordsMatch ? _validFieldValueColor : null, + filled: true, + hintText: "Confirm password", + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + suffixIcon: _password2InFocus + ? IconButton( + icon: Icon( + _password2Visible + ? Icons.visibility + : Icons.visibility_off, + color: Theme.of(context).iconTheme.color, + size: 20, + ), + onPressed: () { + setState(() { + _password2Visible = !_password2Visible; + }); + }, + ) + : _passwordsMatch + ? Icon( + Icons.check, + color: Theme.of(context) + .inputDecorationTheme + .focusedBorder + .borderSide + .color, + ) + : null, + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(6), + ), + ), + focusNode: _password2FocusNode, + onChanged: (cnfPassword) { + setState(() { + _cnfPassword = cnfPassword; + if (_password != null || _password != '') { + _passwordsMatch = _password == _cnfPassword; + } + }); + }, + ), + ), + Opacity( + opacity: (_password != '') && _password1InFocus ? 1 : 0, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Text( + 'Password strength: $passwordStrengthText', + style: TextStyle( + color: passwordStrengthColor, + fontWeight: FontWeight.w500, + fontSize: 12, + ), + ), + ), + ), + const SizedBox(height: 4), + const Divider(thickness: 1), + const SizedBox(height: 12), + _getAgreement(), + const SizedBox(height: 40), + ], + ), + ), + ), + ], + ); + } + + Container _getAgreement() { + return Container( + padding: const EdgeInsets.only(left: 20, right: 20, bottom: 20), + child: Column( + children: [ + _getTOSAgreement(), + _getPasswordAgreement(), + ], + ), + ); + } + + Widget _getTOSAgreement() { + return GestureDetector( + onTap: () { + setState(() { + _hasAgreedToTOS = !_hasAgreedToTOS; + }); + }, + behavior: HitTestBehavior.translucent, + child: Row( + children: [ + Checkbox( + value: _hasAgreedToTOS, + side: CheckboxTheme.of(context).side, + onChanged: (value) { + setState(() { + _hasAgreedToTOS = value; + }); + }, + ), + Expanded( + child: RichText( + text: TextSpan( + children: [ + const TextSpan( + text: "I agree to the ", + ), + TextSpan( + text: "terms of service", + style: const TextStyle( + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const WebPage( + "Terms", + "https://ente.io/terms", + ); + }, + ), + ); + }, + ), + const TextSpan(text: " and "), + TextSpan( + text: "privacy policy", + style: const TextStyle( + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const WebPage( + "Privacy", + "https://ente.io/privacy", + ); + }, + ), + ); + }, + ), + ], + style: Theme.of(context) + .textTheme + .subtitle1 + .copyWith(fontSize: 12), + ), + textAlign: TextAlign.left, + ), + ), + ], + ), + ); + } + + Widget _getPasswordAgreement() { + return GestureDetector( + onTap: () { + setState(() { + _hasAgreedToE2E = !_hasAgreedToE2E; + }); + }, + behavior: HitTestBehavior.translucent, + child: Row( + children: [ + Checkbox( + value: _hasAgreedToE2E, + side: CheckboxTheme.of(context).side, + onChanged: (value) { + setState(() { + _hasAgreedToE2E = value; + }); + }, + ), + Expanded( + child: RichText( + text: TextSpan( + children: [ + const TextSpan( + text: + "I understand that if I lose my password, I may lose my data since my data is ", + ), + TextSpan( + text: "end-to-end encrypted", + style: const TextStyle( + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const WebPage( + "Encryption", + "https://ente.io/architecture", + ); + }, + ), + ); + }, + ), + const TextSpan(text: " with ente"), + ], + style: Theme.of(context) + .textTheme + .subtitle1 + .copyWith(fontSize: 12), + ), + textAlign: TextAlign.left, + ), + ), + ], + ), + ); + } + + bool _isFormValid() { + return _emailIsValid && + _passwordsMatch && + _hasAgreedToTOS && + _hasAgreedToE2E && + _passwordIsValid; + } +} + +class PricingWidget extends StatelessWidget { + const PricingWidget({ + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: BillingService.instance.getBillingPlans(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + return _buildPlans(context, snapshot.data); + } else if (snapshot.hasError) { + return const Text("Oops, Something went wrong."); + } + return const EnteLoadingWidget(); + }, + ); + } + + Container _buildPlans(BuildContext context, BillingPlans plans) { + final planWidgets = []; + for (final plan in plans.plans) { + final productID = Platform.isAndroid ? plan.androidID : plan.iosID; + if (productID != null && productID.isNotEmpty) { + planWidgets.add(BillingPlanWidget(plan)); + } + } + final freePlan = plans.freePlan; + return Container( + height: 280, + color: Theme.of(context).cardColor, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const Text( + "Pricing", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: planWidgets, + ), + ), + Text( + "We offer a free trial of " + + convertBytesToReadableFormat(freePlan.storage) + + " for " + + freePlan.duration.toString() + + " " + + freePlan.period, + ), + GestureDetector( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: const [ + Icon( + Icons.close, + size: 12, + color: Colors.white38, + ), + Padding(padding: EdgeInsets.all(1)), + Text( + "Close", + style: TextStyle( + color: Colors.white38, + ), + ), + ], + ), + onTap: () => Navigator.pop(context), + ) + ], + ), + ); + } +} + +class BillingPlanWidget extends StatelessWidget { + final BillingPlan plan; + + const BillingPlanWidget( + this.plan, { + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(2.0), + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + color: Colors.black.withOpacity(0.2), + child: Container( + padding: const EdgeInsets.fromLTRB(12, 20, 12, 20), + child: Column( + children: [ + Text( + convertBytesToGBs(plan.storage, precision: 0).toString() + + " GB", + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const Padding( + padding: EdgeInsets.all(4), + ), + Text( + plan.price + " / " + plan.period, + style: const TextStyle( + fontSize: 12, + color: Colors.white70, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/account/login_page.dart b/lib/ui/account/login_page.dart new file mode 100644 index 000000000..a0e1f53b0 --- /dev/null +++ b/lib/ui/account/login_page.dart @@ -0,0 +1,213 @@ +// @dart=2.9 + +import 'package:email_validator/email_validator.dart'; +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/services/user_service.dart'; +import 'package:ente_auth/ui/common/dynamic_fab.dart'; +import 'package:ente_auth/ui/common/web_page.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({Key key}) : super(key: key); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _config = Configuration.instance; + bool _emailIsValid = false; + String _email; + Color _emailInputFieldColor; + + @override + void initState() { + _email = _config.getEmail(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final isKeypadOpen = MediaQuery.of(context).viewInsets.bottom > 100; + + FloatingActionButtonLocation fabLocation() { + if (isKeypadOpen) { + return null; + } else { + return FloatingActionButtonLocation.centerFloat; + } + } + + return Scaffold( + resizeToAvoidBottomInset: isKeypadOpen, + appBar: AppBar( + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + color: Theme.of(context).iconTheme.color, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + body: _getBody(), + floatingActionButton: DynamicFAB( + isKeypadOpen: isKeypadOpen, + isFormValid: _emailIsValid, + buttonText: 'Log in', + onPressedFunction: () { + UserService.instance.setEmail(_email); + UserService.instance + .sendOtt(context, _email, isCreateAccountScreen: false); + FocusScope.of(context).unfocus(); + }, + ), + floatingActionButtonLocation: fabLocation(), + floatingActionButtonAnimator: NoScalingAnimation(), + ); + } + + Widget _getBody() { + return Column( + children: [ + Expanded( + child: AutofillGroup( + child: ListView( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(vertical: 30, horizontal: 20), + child: Text( + 'Welcome back!', + style: Theme.of(context).textTheme.headline4, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 0), + child: TextFormField( + autofillHints: const [AutofillHints.email], + decoration: InputDecoration( + fillColor: _emailInputFieldColor, + filled: true, + hintText: 'Email', + contentPadding: const EdgeInsets.symmetric( + horizontal: 15, + vertical: 15, + ), + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(6), + ), + suffixIcon: _emailIsValid + ? Icon( + Icons.check, + size: 20, + color: Theme.of(context) + .inputDecorationTheme + .focusedBorder + .borderSide + .color, + ) + : null, + ), + onChanged: (value) { + setState(() { + _email = value.trim(); + _emailIsValid = EmailValidator.validate(_email); + if (_emailIsValid) { + _emailInputFieldColor = + const Color.fromARGB(51, 157, 45, 194); + } else { + _emailInputFieldColor = null; + } + }); + }, + autocorrect: false, + keyboardType: TextInputType.emailAddress, + //initialValue: _email, + autofocus: true, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 18), + child: Divider( + thickness: 1, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Expanded( + flex: 5, + child: RichText( + text: TextSpan( + style: Theme.of(context) + .textTheme + .subtitle1 + .copyWith(fontSize: 12), + children: [ + const TextSpan( + text: "By clicking log in, I agree to the ", + ), + TextSpan( + text: "terms of service", + style: const TextStyle( + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const WebPage( + "terms", + "https://ente.io/terms", + ); + }, + ), + ); + }, + ), + const TextSpan(text: " and "), + TextSpan( + text: "privacy policy", + style: const TextStyle( + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const WebPage( + "privacy", + "https://ente.io/privacy", + ); + }, + ), + ); + }, + ), + ], + ), + textAlign: TextAlign.left, + ), + ), + Expanded( + flex: 2, + child: Container(), + ) + ], + ), + ), + ], + ), + ), + ), + const Padding(padding: EdgeInsets.all(8)), + ], + ); + } +} diff --git a/lib/ui/account/ott_verification_page.dart b/lib/ui/account/ott_verification_page.dart new file mode 100644 index 000000000..c9d74bf6e --- /dev/null +++ b/lib/ui/account/ott_verification_page.dart @@ -0,0 +1,205 @@ +// @dart=2.9 + +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/services/user_service.dart'; +import 'package:ente_auth/ui/common/dynamic_fab.dart'; +import 'package:flutter/material.dart'; +import 'package:step_progress_indicator/step_progress_indicator.dart'; + +class OTTVerificationPage extends StatefulWidget { + final String email; + final bool isChangeEmail; + final bool isCreateAccountScreen; + + const OTTVerificationPage( + this.email, { + this.isChangeEmail = false, + this.isCreateAccountScreen = false, + Key key, + }) : super(key: key); + + @override + State createState() => _OTTVerificationPageState(); +} + +class _OTTVerificationPageState extends State { + final _verificationCodeController = TextEditingController(); + + @override + Widget build(BuildContext context) { + final isKeypadOpen = MediaQuery.of(context).viewInsets.bottom > 100; + + FloatingActionButtonLocation fabLocation() { + if (isKeypadOpen) { + return null; + } else { + return FloatingActionButtonLocation.centerFloat; + } + } + + return Scaffold( + resizeToAvoidBottomInset: isKeypadOpen, + appBar: AppBar( + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + color: Theme.of(context).iconTheme.color, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: widget.isCreateAccountScreen + ? Material( + type: MaterialType.transparency, + child: StepProgressIndicator( + totalSteps: 4, + currentStep: 2, + selectedColor: Theme.of(context).colorScheme.alternativeColor, + roundedEdges: const Radius.circular(10), + unselectedColor: + Theme.of(context).colorScheme.stepProgressUnselectedColor, + ), + ) + : null, + ), + body: _getBody(), + floatingActionButton: DynamicFAB( + isKeypadOpen: isKeypadOpen, + isFormValid: !(_verificationCodeController.text == null || + _verificationCodeController.text.isEmpty), + buttonText: 'Verify', + onPressedFunction: () { + if (widget.isChangeEmail) { + UserService.instance.changeEmail( + context, + widget.email, + _verificationCodeController.text, + ); + } else { + UserService.instance + .verifyEmail(context, _verificationCodeController.text); + } + FocusScope.of(context).unfocus(); + }, + ), + floatingActionButtonLocation: fabLocation(), + floatingActionButtonAnimator: NoScalingAnimation(), + ); + } + + Widget _getBody() { + return ListView( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 30, 20, 15), + child: Text( + 'Verify email', + style: Theme.of(context).textTheme.headline4, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 12), + child: RichText( + text: TextSpan( + style: Theme.of(context) + .textTheme + .subtitle1 + .copyWith(fontSize: 14), + children: [ + const TextSpan(text: "We've sent a mail to "), + TextSpan( + text: widget.email, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .alternativeColor, + ), + ) + ], + ), + ), + ), + Text( + 'Please check your inbox (and spam) to complete verification', + style: Theme.of(context) + .textTheme + .subtitle1 + .copyWith(fontSize: 14), + ), + ], + ), + ), + SizedBox( + width: MediaQuery.of(context).size.width * 0.2, + height: 1, + ) + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + child: TextFormField( + style: Theme.of(context).textTheme.subtitle1, + decoration: InputDecoration( + filled: true, + hintText: 'Tap to enter code', + contentPadding: const EdgeInsets.all(15), + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(6), + ), + ), + controller: _verificationCodeController, + autofocus: false, + autocorrect: false, + keyboardType: TextInputType.number, + onChanged: (_) { + setState(() {}); + }, + ), + ), + const Divider( + thickness: 1, + ), + Padding( + padding: const EdgeInsets.all(20), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () { + UserService.instance.sendOtt( + context, + widget.email, + isCreateAccountScreen: widget.isCreateAccountScreen, + ); + }, + child: Text( + "Resend email", + style: Theme.of(context).textTheme.subtitle1.copyWith( + fontSize: 14, + decoration: TextDecoration.underline, + ), + ), + ) + ], + ), + ), + ], + ), + ], + ); + // ); + } +} diff --git a/lib/ui/account/password_entry_page.dart b/lib/ui/account/password_entry_page.dart new file mode 100644 index 000000000..31cafd838 --- /dev/null +++ b/lib/ui/account/password_entry_page.dart @@ -0,0 +1,461 @@ +// @dart=2.9 + +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/events/account_configured_event.dart'; +import 'package:ente_auth/events/subscription_purchased_event.dart'; +import 'package:ente_auth/services/user_service.dart'; +import 'package:ente_auth/ui/account/recovery_key_page.dart'; +import 'package:ente_auth/ui/common/dynamic_fab.dart'; +import 'package:ente_auth/ui/common/web_page.dart'; +import 'package:ente_auth/ui/home_page.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/navigation_util.dart'; +import 'package:ente_auth/utils/toast_util.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; +import 'package:password_strength/password_strength.dart'; + +enum PasswordEntryMode { + set, + update, + reset, +} + +class PasswordEntryPage extends StatefulWidget { + final PasswordEntryMode mode; + + const PasswordEntryPage({this.mode = PasswordEntryMode.set, Key key}) + : super(key: key); + + @override + State createState() => _PasswordEntryPageState(); +} + +class _PasswordEntryPageState extends State { + static const kMildPasswordStrengthThreshold = 0.4; + static const kStrongPasswordStrengthThreshold = 0.7; + + final _logger = Logger((_PasswordEntryPageState).toString()); + final _passwordController1 = TextEditingController(), + _passwordController2 = TextEditingController(); + final Color _validFieldValueColor = const Color.fromRGBO(45, 194, 98, 0.2); + String _volatilePassword; + String _passwordInInputBox = ''; + String _passwordInInputConfirmationBox = ''; + double _passwordStrength = 0.0; + bool _password1Visible = false; + bool _password2Visible = false; + final _password1FocusNode = FocusNode(); + final _password2FocusNode = FocusNode(); + bool _password1InFocus = false; + bool _password2InFocus = false; + + bool _passwordsMatch = false; + bool _isPasswordValid = false; + + @override + void initState() { + super.initState(); + _volatilePassword = Configuration.instance.getVolatilePassword(); + if (_volatilePassword != null) { + Future.delayed( + Duration.zero, + () => _showRecoveryCodeDialog(_volatilePassword), + ); + } + _password1FocusNode.addListener(() { + setState(() { + _password1InFocus = _password1FocusNode.hasFocus; + }); + }); + _password2FocusNode.addListener(() { + setState(() { + _password2InFocus = _password2FocusNode.hasFocus; + }); + }); + } + + @override + Widget build(BuildContext context) { + final isKeypadOpen = MediaQuery.of(context).viewInsets.bottom > 100; + + FloatingActionButtonLocation fabLocation() { + if (isKeypadOpen) { + return null; + } else { + return FloatingActionButtonLocation.centerFloat; + } + } + + String title = "Set password"; + if (widget.mode == PasswordEntryMode.update) { + title = "Change password"; + } else if (widget.mode == PasswordEntryMode.reset) { + title = "Reset password"; + } else if (_volatilePassword != null) { + title = "Encryption keys"; + } + return Scaffold( + resizeToAvoidBottomInset: isKeypadOpen, + appBar: AppBar( + leading: widget.mode == PasswordEntryMode.reset + ? Container() + : IconButton( + icon: const Icon(Icons.arrow_back), + color: Theme.of(context).iconTheme.color, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + elevation: 0, + ), + body: _getBody(title), + floatingActionButton: DynamicFAB( + isKeypadOpen: isKeypadOpen, + isFormValid: _passwordsMatch && _isPasswordValid, + buttonText: title, + onPressedFunction: () { + if (widget.mode == PasswordEntryMode.set) { + _showRecoveryCodeDialog(_passwordController1.text); + } else { + _updatePassword(); + } + FocusScope.of(context).unfocus(); + }, + ), + floatingActionButtonLocation: fabLocation(), + floatingActionButtonAnimator: NoScalingAnimation(), + ); + } + + Widget _getBody(String buttonTextAndHeading) { + final email = Configuration.instance.getEmail(); + var passwordStrengthText = 'Weak'; + var passwordStrengthColor = Colors.redAccent; + if (_passwordStrength > kStrongPasswordStrengthThreshold) { + passwordStrengthText = 'Strong'; + passwordStrengthColor = Colors.greenAccent; + } else if (_passwordStrength > kMildPasswordStrengthThreshold) { + passwordStrengthText = 'Moderate'; + passwordStrengthColor = Colors.orangeAccent; + } + if (_volatilePassword != null) { + return Container(); + } + return Column( + children: [ + Expanded( + child: AutofillGroup( + child: ListView( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(vertical: 30, horizontal: 20), + child: Text( + buttonTextAndHeading, + style: Theme.of(context).textTheme.headline4, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + "Enter a" + + (widget.mode != PasswordEntryMode.set ? " new " : " ") + + "password we can use to encrypt your data", + textAlign: TextAlign.start, + style: Theme.of(context) + .textTheme + .subtitle1 + .copyWith(fontSize: 14), + ), + ), + const Padding(padding: EdgeInsets.all(8)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: RichText( + text: TextSpan( + style: Theme.of(context) + .textTheme + .subtitle1 + .copyWith(fontSize: 14), + children: [ + const TextSpan( + text: + "We don't store this password, so if you forget, ", + ), + TextSpan( + text: "we cannot decrypt your data", + style: Theme.of(context).textTheme.subtitle1.copyWith( + fontSize: 14, + decoration: TextDecoration.underline, + ), + ), + ], + ), + ), + ), + const Padding(padding: EdgeInsets.all(12)), + Visibility( + // hidden textForm for suggesting auto-fill service for saving + // password + visible: false, + child: TextFormField( + autofillHints: const [ + AutofillHints.email, + ], + autocorrect: false, + keyboardType: TextInputType.emailAddress, + initialValue: email, + textInputAction: TextInputAction.next, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), + child: TextFormField( + autofillHints: const [AutofillHints.newPassword], + decoration: InputDecoration( + fillColor: + _isPasswordValid ? _validFieldValueColor : null, + filled: true, + hintText: "Password", + contentPadding: const EdgeInsets.all(20), + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(6), + ), + suffixIcon: _password1InFocus + ? IconButton( + icon: Icon( + _password1Visible + ? Icons.visibility + : Icons.visibility_off, + color: Theme.of(context).iconTheme.color, + size: 20, + ), + onPressed: () { + setState(() { + _password1Visible = !_password1Visible; + }); + }, + ) + : _isPasswordValid + ? Icon( + Icons.check, + color: Theme.of(context) + .inputDecorationTheme + .focusedBorder + .borderSide + .color, + ) + : null, + ), + obscureText: !_password1Visible, + controller: _passwordController1, + autofocus: false, + autocorrect: false, + keyboardType: TextInputType.visiblePassword, + onChanged: (password) { + setState(() { + _passwordInInputBox = password; + _passwordStrength = estimatePasswordStrength(password); + _isPasswordValid = + _passwordStrength >= kMildPasswordStrengthThreshold; + _passwordsMatch = _passwordInInputBox == + _passwordInInputConfirmationBox; + }); + }, + textInputAction: TextInputAction.next, + focusNode: _password1FocusNode, + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), + child: TextFormField( + keyboardType: TextInputType.visiblePassword, + controller: _passwordController2, + obscureText: !_password2Visible, + autofillHints: const [AutofillHints.newPassword], + onEditingComplete: () => TextInput.finishAutofillContext(), + decoration: InputDecoration( + fillColor: _passwordsMatch ? _validFieldValueColor : null, + filled: true, + hintText: "Confirm password", + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 20, + ), + suffixIcon: _password2InFocus + ? IconButton( + icon: Icon( + _password2Visible + ? Icons.visibility + : Icons.visibility_off, + color: Theme.of(context).iconTheme.color, + size: 20, + ), + onPressed: () { + setState(() { + _password2Visible = !_password2Visible; + }); + }, + ) + : _passwordsMatch + ? Icon( + Icons.check, + color: Theme.of(context) + .inputDecorationTheme + .focusedBorder + .borderSide + .color, + ) + : null, + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(6), + ), + ), + focusNode: _password2FocusNode, + onChanged: (cnfPassword) { + setState(() { + _passwordInInputConfirmationBox = cnfPassword; + if (_passwordInInputBox != null || + _passwordInInputBox != '') { + _passwordsMatch = _passwordInInputBox == + _passwordInInputConfirmationBox; + } + }); + }, + ), + ), + Opacity( + opacity: + (_passwordInInputBox != '') && _password1InFocus ? 1 : 0, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Text( + 'Password Strength: $passwordStrengthText', + style: TextStyle( + color: passwordStrengthColor, + ), + ), + ), + ), + const SizedBox(height: 8), + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const WebPage( + "How it works", + "https://ente.io/architecture", + ); + }, + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: RichText( + text: TextSpan( + text: "How it works", + style: Theme.of(context).textTheme.subtitle1.copyWith( + fontSize: 14, + decoration: TextDecoration.underline, + ), + ), + ), + ), + ), + const Padding(padding: EdgeInsets.all(20)), + ], + ), + ), + ), + ], + ); + } + + void _updatePassword() async { + final dialog = + createProgressDialog(context, "Generating encryption keys..."); + await dialog.show(); + try { + final keyAttributes = await Configuration.instance + .updatePassword(_passwordController1.text); + await UserService.instance.updateKeyAttributes(keyAttributes); + await dialog.hide(); + showShortToast(context, "Password changed successfully"); + Navigator.of(context).pop(); + if (widget.mode == PasswordEntryMode.reset) { + Bus.instance.fire(SubscriptionPurchasedEvent()); + Navigator.of(context).popUntil((route) => route.isFirst); + } + } catch (e, s) { + _logger.severe(e, s); + await dialog.hide(); + showGenericErrorDialog(context); + } + } + + Future _showRecoveryCodeDialog(String password) async { + final dialog = + createProgressDialog(context, "Generating encryption keys..."); + await dialog.show(); + try { + final result = await Configuration.instance.generateKey(password); + Configuration.instance.setVolatilePassword(null); + await dialog.hide(); + onDone() async { + final dialog = createProgressDialog(context, "Please wait..."); + await dialog.show(); + try { + await UserService.instance.setAttributes(result); + await dialog.hide(); + Bus.instance.fire(AccountConfiguredEvent()); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const HomePage(); + }, + ), + (route) => route.isFirst, + ); + } catch (e, s) { + _logger.severe(e, s); + await dialog.hide(); + showGenericErrorDialog(context); + } + } + + routeToPage( + context, + RecoveryKeyPage( + result.privateKeyAttributes.recoveryKey, + "Continue", + showAppBar: false, + isDismissible: false, + onDone: onDone, + showProgressBar: true, + ), + ); + } catch (e) { + _logger.severe(e); + await dialog.hide(); + if (e is UnsupportedError) { + showErrorDialog( + context, + "Insecure device", + "Sorry, we could not generate secure keys on this device.\n\nplease sign up from a different device.", + ); + } else { + showGenericErrorDialog(context); + } + } + } +} diff --git a/lib/ui/account/password_reentry_page.dart b/lib/ui/account/password_reentry_page.dart new file mode 100644 index 000000000..cd26ef315 --- /dev/null +++ b/lib/ui/account/password_reentry_page.dart @@ -0,0 +1,289 @@ +// @dart=2.9 + +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/errors.dart'; +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/events/subscription_purchased_event.dart'; +import 'package:ente_auth/models/key_attributes.dart'; +import 'package:ente_auth/ui/account/recovery_page.dart'; +import 'package:ente_auth/ui/common/dialogs.dart'; +import 'package:ente_auth/ui/common/dynamic_fab.dart'; +import 'package:ente_auth/ui/home_page.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/email_util.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; + +class PasswordReentryPage extends StatefulWidget { + const PasswordReentryPage({Key key}) : super(key: key); + + @override + State createState() => _PasswordReentryPageState(); +} + +class _PasswordReentryPageState extends State { + final _logger = Logger((_PasswordReentryPageState).toString()); + final _passwordController = TextEditingController(); + final FocusNode _passwordFocusNode = FocusNode(); + String email; + bool _passwordInFocus = false; + bool _passwordVisible = false; + + @override + void initState() { + super.initState(); + email = Configuration.instance.getEmail(); + _passwordFocusNode.addListener(() { + setState(() { + _passwordInFocus = _passwordFocusNode.hasFocus; + }); + }); + } + + @override + Widget build(BuildContext context) { + final isKeypadOpen = MediaQuery.of(context).viewInsets.bottom > 100; + + FloatingActionButtonLocation fabLocation() { + if (isKeypadOpen) { + return null; + } else { + return FloatingActionButtonLocation.centerFloat; + } + } + + return Scaffold( + resizeToAvoidBottomInset: isKeypadOpen, + appBar: AppBar( + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + color: Theme.of(context).iconTheme.color, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + body: _getBody(), + floatingActionButton: DynamicFAB( + isKeypadOpen: isKeypadOpen, + isFormValid: _passwordController.text.isNotEmpty, + buttonText: 'Verify password', + onPressedFunction: () async { + FocusScope.of(context).unfocus(); + final dialog = createProgressDialog(context, "Please wait..."); + await dialog.show(); + try { + await Configuration.instance.decryptAndSaveSecrets( + _passwordController.text, + Configuration.instance.getKeyAttributes(), + ); + } on KeyDerivationError catch (e, s) { + _logger.severe("Password verification failed", e, s); + await dialog.hide(); + final dialogUserChoice = await showChoiceDialog( + context, + "Recreate password", + "The current device is not powerful enough to verify your " + "password, so we need to regenerate it once in a way that " + "works with all devices. \n\nPlease login using your " + "recovery key and regenerate your password (you can use the same one again if you wish).", + firstAction: "Cancel", + firstActionColor: Theme.of(context).colorScheme.primary, + secondAction: "Use recovery key", + secondActionColor: Theme.of(context).colorScheme.primary, + ); + if (dialogUserChoice == DialogUserChoice.secondChoice) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const RecoveryPage(); + }, + ), + ); + } + return; + } catch (e, s) { + _logger.severe("Password verification failed", e, s); + await dialog.hide(); + + final dialogUserChoice = await showChoiceDialog( + context, + "Incorrect password", + "Please try again", + firstAction: "Contact Support", + firstActionColor: Theme.of(context).colorScheme.primary, + secondAction: "Ok", + secondActionColor: Theme.of(context).colorScheme.primary, + ); + if (dialogUserChoice == DialogUserChoice.firstChoice) { + await sendLogs( + context, + "Contact support", + "support@ente.io", + postShare: () {}, + ); + } + return; + } + await dialog.hide(); + Bus.instance.fire(SubscriptionPurchasedEvent()); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const HomePage(); + }, + ), + (route) => false, + ); + }, + ), + floatingActionButtonLocation: fabLocation(), + floatingActionButtonAnimator: NoScalingAnimation(), + ); + } + + Widget _getBody() { + return Column( + children: [ + Expanded( + child: AutofillGroup( + child: ListView( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(vertical: 30, horizontal: 20), + child: Text( + 'Welcome back!', + style: Theme.of(context).textTheme.headline4, + ), + ), + Visibility( + // hidden textForm for suggesting auto-fill service for saving + // password + visible: false, + child: TextFormField( + autofillHints: const [ + AutofillHints.email, + ], + autocorrect: false, + keyboardType: TextInputType.emailAddress, + initialValue: email, + textInputAction: TextInputAction.next, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 0), + child: TextFormField( + autofillHints: const [AutofillHints.password], + decoration: InputDecoration( + hintText: "Enter your password", + filled: true, + contentPadding: const EdgeInsets.all(20), + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(6), + ), + suffixIcon: _passwordInFocus + ? IconButton( + icon: Icon( + _passwordVisible + ? Icons.visibility + : Icons.visibility_off, + color: Theme.of(context).iconTheme.color, + size: 20, + ), + onPressed: () { + setState(() { + _passwordVisible = !_passwordVisible; + }); + }, + ) + : null, + ), + style: const TextStyle( + fontSize: 14, + ), + controller: _passwordController, + autofocus: true, + autocorrect: false, + obscureText: !_passwordVisible, + keyboardType: TextInputType.visiblePassword, + focusNode: _passwordFocusNode, + onChanged: (_) { + setState(() {}); + }, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 18), + child: Divider( + thickness: 1, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const RecoveryPage(); + }, + ), + ); + }, + child: Center( + child: Text( + "Forgot password", + style: + Theme.of(context).textTheme.subtitle1.copyWith( + fontSize: 14, + decoration: TextDecoration.underline, + ), + ), + ), + ), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () async { + final dialog = + createProgressDialog(context, "Please wait..."); + await dialog.show(); + await Configuration.instance.logout(); + await dialog.hide(); + Navigator.of(context) + .popUntil((route) => route.isFirst); + }, + child: Center( + child: Text( + "Change email", + style: + Theme.of(context).textTheme.subtitle1.copyWith( + fontSize: 14, + decoration: TextDecoration.underline, + ), + ), + ), + ), + ], + ), + ) + ], + ), + ), + ), + ], + ); + } + + void validatePreVerificationState(KeyAttributes keyAttributes) { + if (keyAttributes == null) { + throw Exception("Key Attributes can not be null"); + } + } +} diff --git a/lib/ui/account/recovery_key_page.dart b/lib/ui/account/recovery_key_page.dart new file mode 100644 index 000000000..50893b3ce --- /dev/null +++ b/lib/ui/account/recovery_key_page.dart @@ -0,0 +1,270 @@ +// @dart=2.9 + +import 'dart:io' as io; + +import 'package:bip39/bip39.dart' as bip39; +import 'package:dotted_border/dotted_border.dart'; +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/constants.dart'; +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/ui/common/gradient_button.dart'; +import 'package:ente_auth/utils/toast_util.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:step_progress_indicator/step_progress_indicator.dart'; + +class RecoveryKeyPage extends StatefulWidget { + final bool showAppBar; + final String recoveryKey; + final String doneText; + final Function() onDone; + final bool isDismissible; + final String title; + final String text; + final String subText; + final bool showProgressBar; + + const RecoveryKeyPage( + this.recoveryKey, + this.doneText, { + Key key, + this.showAppBar, + this.onDone, + this.isDismissible, + this.title, + this.text, + this.subText, + this.showProgressBar = false, + }) : super(key: key); + + @override + State createState() => _RecoveryKeyPageState(); +} + +class _RecoveryKeyPageState extends State { + bool _hasTriedToSave = false; + final _recoveryKeyFile = io.File( + Configuration.instance.getTempDirectory() + "ente-recovery-key.txt", + ); + + @override + Widget build(BuildContext context) { + final String recoveryKey = bip39.entropyToMnemonic(widget.recoveryKey); + if (recoveryKey.split(' ').length != mnemonicKeyWordCount) { + throw AssertionError( + 'recovery code should have $mnemonicKeyWordCount words', + ); + } + final double topPadding = widget.showAppBar + ? 40 + : widget.showProgressBar + ? 32 + : 120; + + return Scaffold( + appBar: widget.showProgressBar + ? AppBar( + elevation: 0, + title: Hero( + tag: "recovery_key", + child: StepProgressIndicator( + totalSteps: 4, + currentStep: 3, + selectedColor: Theme.of(context).colorScheme.alternativeColor, + roundedEdges: const Radius.circular(10), + unselectedColor: + Theme.of(context).colorScheme.stepProgressUnselectedColor, + ), + ), + ) + : widget.showAppBar + ? AppBar( + elevation: 0, + title: Text(widget.title ?? "Recovery key"), + ) + : null, + body: Padding( + padding: EdgeInsets.fromLTRB(20, topPadding, 20, 20), + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: constraints.maxWidth, + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + widget.showAppBar + ? const SizedBox.shrink() + : Text( + widget.title ?? "Recovery key", + style: Theme.of(context).textTheme.headline4, + ), + Padding( + padding: EdgeInsets.all(widget.showAppBar ? 0 : 12), + ), + Text( + widget.text ?? + "If you forget your password, the only way you can recover your data is with this key.", + style: Theme.of(context).textTheme.subtitle1, + ), + const Padding(padding: EdgeInsets.only(top: 24)), + DottedBorder( + color: const Color.fromARGB(255, 105, 17, 127), + //color of dotted/dash line + strokeWidth: 1, + //thickness of dash/dots + dashPattern: const [6, 6], + radius: const Radius.circular(8), + //dash patterns, 10 is dash width, 6 is space width + child: SizedBox( + //inner container + // height: 120, //height of inner container + width: double + .infinity, //width to 100% match to parent container. + // ignore: prefer_const_literals_to_create_immutables + child: Column( + children: [ + GestureDetector( + onTap: () async { + await Clipboard.setData( + ClipboardData(text: recoveryKey), + ); + showToast( + context, + "Recovery key copied to clipboard", + ); + setState(() { + _hasTriedToSave = true; + }); + }, + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: const Color.fromARGB( + 51, + 147, + 43, + 200, + ), + ), + borderRadius: const BorderRadius.all( + Radius.circular(2), + ), + color: Theme.of(context) + .colorScheme + .recoveryKeyBoxColor, + ), + padding: const EdgeInsets.all(20), + width: double.infinity, + child: Text( + recoveryKey, + style: + Theme.of(context).textTheme.bodyText1, + ), + ), + ), + ], + ), + ), + ), + SizedBox( + height: 80, + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Text( + widget.subText ?? + "We donโ€™t store this key, please save this in a safe place.", + style: Theme.of(context).textTheme.bodyText1, + ), + ), + ), + Expanded( + child: Container( + alignment: Alignment.bottomCenter, + width: double.infinity, + padding: const EdgeInsets.fromLTRB(10, 10, 10, 42), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _saveOptions(context, recoveryKey), + ), + ), + ) + ], + ), // columnEnds + ), + ), + ); + }, + ), + ), + ); + } + + List _saveOptions(BuildContext context, String recoveryKey) { + final List childrens = []; + if (!_hasTriedToSave) { + childrens.add( + ElevatedButton( + style: Theme.of(context).colorScheme.optionalActionButtonStyle, + onPressed: () async { + await _saveKeys(); + }, + child: const Text('Do this later'), + ), + ); + childrens.add(const SizedBox(height: 10)); + } + + childrens.add( + GradientButton( + onTap: () async { + await _shareRecoveryKey(recoveryKey); + }, + text: 'Save key', + ), + ); + if (_hasTriedToSave) { + childrens.add(const SizedBox(height: 10)); + childrens.add( + ElevatedButton( + child: Text(widget.doneText), + onPressed: () async { + await _saveKeys(); + }, + ), + ); + } + childrens.add(const SizedBox(height: 12)); + return childrens; + } + + Future _shareRecoveryKey(String recoveryKey) async { + if (_recoveryKeyFile.existsSync()) { + await _recoveryKeyFile.delete(); + } + _recoveryKeyFile.writeAsStringSync(recoveryKey); + await Share.shareFiles([_recoveryKeyFile.path]); + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) { + setState(() { + _hasTriedToSave = true; + }); + } + }); + } + + Future _saveKeys() async { + Navigator.of(context).pop(); + if (_recoveryKeyFile.existsSync()) { + await _recoveryKeyFile.delete(); + } + widget.onDone(); + } +} diff --git a/lib/ui/account/recovery_page.dart b/lib/ui/account/recovery_page.dart new file mode 100644 index 000000000..24c574072 --- /dev/null +++ b/lib/ui/account/recovery_page.dart @@ -0,0 +1,160 @@ +// @dart=2.9 + +import 'dart:ui'; + +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/ui/account/password_entry_page.dart'; +import 'package:ente_auth/ui/common/dynamic_fab.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/toast_util.dart'; +import 'package:flutter/material.dart'; + +class RecoveryPage extends StatefulWidget { + const RecoveryPage({Key key}) : super(key: key); + + @override + State createState() => _RecoveryPageState(); +} + +class _RecoveryPageState extends State { + final _recoveryKey = TextEditingController(); + + @override + Widget build(BuildContext context) { + final isKeypadOpen = MediaQuery.of(context).viewInsets.bottom > 100; + FloatingActionButtonLocation fabLocation() { + if (isKeypadOpen) { + return null; + } else { + return FloatingActionButtonLocation.centerFloat; + } + } + + return Scaffold( + resizeToAvoidBottomInset: isKeypadOpen, + appBar: AppBar( + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + color: Theme.of(context).iconTheme.color, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + floatingActionButton: DynamicFAB( + isKeypadOpen: isKeypadOpen, + isFormValid: _recoveryKey.text.isNotEmpty, + buttonText: 'Recover', + onPressedFunction: () async { + FocusScope.of(context).unfocus(); + final dialog = createProgressDialog(context, "Decrypting..."); + await dialog.show(); + try { + await Configuration.instance.recover(_recoveryKey.text.trim()); + await dialog.hide(); + showToast(context, "Recovery successful!"); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (BuildContext context) { + return WillPopScope( + onWillPop: () async => false, + child: const PasswordEntryPage( + mode: PasswordEntryMode.reset, + ), + ); + }, + ), + ); + } catch (e) { + await dialog.hide(); + String errMessage = 'the recovery key you entered is incorrect'; + if (e is AssertionError) { + errMessage = '$errMessage : ${e.message}'; + } + showErrorDialog(context, "Incorrect recovery key", errMessage); + } + }, + ), + floatingActionButtonLocation: fabLocation(), + floatingActionButtonAnimator: NoScalingAnimation(), + body: Column( + children: [ + Expanded( + child: ListView( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(vertical: 30, horizontal: 20), + child: Text( + 'Forgot password', + style: Theme.of(context).textTheme.headline4, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 0), + child: TextFormField( + decoration: InputDecoration( + filled: true, + hintText: "Enter your recovery key", + contentPadding: const EdgeInsets.all(20), + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(6), + ), + ), + style: const TextStyle( + fontSize: 14, + fontFeatures: [FontFeature.tabularFigures()], + ), + controller: _recoveryKey, + autofocus: false, + autocorrect: false, + keyboardType: TextInputType.multiline, + maxLines: null, + onChanged: (_) { + setState(() {}); + }, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 18), + child: Divider( + thickness: 1, + ), + ), + Row( + children: [ + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + showErrorDialog( + context, + "Sorry", + "Due to the nature of our end-to-end encryption protocol, your data cannot be decrypted without your password or recovery key", + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Center( + child: Text( + "No recovery key?", + style: + Theme.of(context).textTheme.subtitle1.copyWith( + fontSize: 14, + decoration: TextDecoration.underline, + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/account/sessions_page.dart b/lib/ui/account/sessions_page.dart new file mode 100644 index 000000000..16070c9a8 --- /dev/null +++ b/lib/ui/account/sessions_page.dart @@ -0,0 +1,224 @@ +// @dart=2.9 + +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/models/sessions.dart'; +import 'package:ente_auth/services/user_service.dart'; +import 'package:ente_auth/ui/common/loading_widget.dart'; +import 'package:ente_auth/utils/date_time_util.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/toast_util.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; + +class SessionsPage extends StatefulWidget { + const SessionsPage({Key key}) : super(key: key); + + @override + State createState() => _SessionsPageState(); +} + +class _SessionsPageState extends State { + Sessions _sessions; + final Logger _logger = Logger("SessionsPageState"); + + @override + void initState() { + _fetchActiveSessions(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + elevation: 0, + title: const Text("Active sessions"), + ), + body: _getBody(), + ); + } + + Widget _getBody() { + if (_sessions == null) { + return const Center(child: EnteLoadingWidget()); + } + final List rows = []; + rows.add(const Padding(padding: EdgeInsets.all(4))); + for (final session in _sessions.sessions) { + rows.add(_getSessionWidget(session)); + } + return SingleChildScrollView( + child: Column( + children: rows, + ), + ); + } + + Widget _getSessionWidget(Session session) { + final lastUsedTime = + DateTime.fromMicrosecondsSinceEpoch(session.lastUsedTime); + return Column( + children: [ + InkWell( + onTap: () async { + _showSessionTerminationDialog(session); + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _getUAWidget(session), + const Padding(padding: EdgeInsets.all(4)), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + session.ip, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.8), + fontSize: 14, + ), + ), + ), + const Padding(padding: EdgeInsets.all(8)), + Flexible( + child: Text( + getFormattedTime(lastUsedTime), + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(0.8), + fontSize: 12, + ), + ), + ), + ], + ), + ], + ), + ), + ), + const Divider(), + ], + ); + } + + Future _terminateSession(Session session) async { + final dialog = createProgressDialog(context, "Please wait..."); + await dialog.show(); + try { + await UserService.instance.terminateSession(session.token); + await _fetchActiveSessions(); + await dialog.hide(); + } catch (e, s) { + await dialog.hide(); + _logger.severe('failed to terminate', e, s); + showErrorDialog( + context, + 'Oops', + "Something went wrong, please try again", + ); + } + } + + Future _fetchActiveSessions() async { + _sessions = await UserService.instance + .getActiveSessions() + .onError((error, stackTrace) { + showToast(context, "Failed to fetch active sessions"); + throw error; + }); + _sessions.sessions.sort((first, second) { + return second.lastUsedTime.compareTo(first.lastUsedTime); + }); + setState(() {}); + } + + void _showSessionTerminationDialog(Session session) { + final isLoggingOutFromThisDevice = + session.token == Configuration.instance.getToken(); + Widget text; + if (isLoggingOutFromThisDevice) { + text = const Text( + "This will log you out of this device!", + ); + } else { + text = SingleChildScrollView( + child: Column( + children: [ + const Text( + "This will log you out of the following device:", + ), + const Padding(padding: EdgeInsets.all(8)), + Text( + session.ua, + style: Theme.of(context).textTheme.caption, + ), + ], + ), + ); + } + final AlertDialog alert = AlertDialog( + title: const Text("Terminate session?"), + content: text, + actions: [ + TextButton( + child: const Text( + "Terminate", + style: TextStyle( + color: Colors.red, + ), + ), + onPressed: () async { + Navigator.of(context, rootNavigator: true).pop('dialog'); + if (isLoggingOutFromThisDevice) { + await UserService.instance.logout(context); + } else { + _terminateSession(session); + } + }, + ), + TextButton( + child: Text( + "Cancel", + style: TextStyle( + color: isLoggingOutFromThisDevice + ? Theme.of(context).colorScheme.alternativeColor + : Theme.of(context).colorScheme.defaultTextColor, + ), + ), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop('dialog'); + }, + ), + ], + ); + + showDialog( + context: context, + builder: (BuildContext context) { + return alert; + }, + ); + } + + Widget _getUAWidget(Session session) { + if (session.token == Configuration.instance.getToken()) { + return Text( + "This device", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.alternativeColor, + ), + ); + } + return Text(session.prettyUA); + } +} diff --git a/lib/ui/account/verify_recovery_page.dart b/lib/ui/account/verify_recovery_page.dart new file mode 100644 index 000000000..88234383a --- /dev/null +++ b/lib/ui/account/verify_recovery_page.dart @@ -0,0 +1,225 @@ +// ignore_for_file: import_of_legacy_library_into_null_safe + +import 'dart:ui'; + +import 'package:bip39/bip39.dart' as bip39; +import 'package:dio/dio.dart'; +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/events/notification_event.dart'; +import 'package:ente_auth/services/local_authentication_service.dart'; +import 'package:ente_auth/services/user_remote_flag_service.dart'; +import 'package:ente_auth/ui/account/recovery_key_page.dart'; +import 'package:ente_auth/ui/common/dialogs.dart'; +import 'package:ente_auth/ui/common/gradient_button.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/navigation_util.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_sodium/flutter_sodium.dart'; +import 'package:logging/logging.dart'; + +class VerifyRecoveryPage extends StatefulWidget { + const VerifyRecoveryPage({Key? key}) : super(key: key); + + @override + State createState() => _VerifyRecoveryPageState(); +} + +class _VerifyRecoveryPageState extends State { + final _recoveryKey = TextEditingController(); + final Logger _logger = Logger((_VerifyRecoveryPageState).toString()); + + void _verifyRecoveryKey() async { + final dialog = createProgressDialog(context, "Verifying recovery key..."); + await dialog.show(); + try { + final String inputKey = _recoveryKey.text.trim(); + final String recoveryKey = + Sodium.bin2hex(Configuration.instance.getRecoveryKey()); + final String recoveryKeyWords = bip39.entropyToMnemonic(recoveryKey); + if (inputKey == recoveryKey || inputKey == recoveryKeyWords) { + try { + await UserRemoteFlagService.instance.markRecoveryVerificationAsDone(); + } catch (e) { + await dialog.hide(); + if (e is DioError && e.type == DioErrorType.other) { + await showErrorDialog( + context, + "No internet connection", + "Please check your internet connection and try again.", + ); + } else { + await showGenericErrorDialog(context); + } + return; + } + Bus.instance.fire(NotificationEvent()); + await dialog.hide(); + // todo: change this as per figma once the component is ready + await showErrorDialog( + context, + "Recovery key verified", + "Great! Your recovery key is valid. Thank you for verifying.\n" + "\nPlease" + " remember to keep your recovery key safely backed up.", + ); + Navigator.of(context).pop(); + } else { + throw Exception("recovery key didn't match"); + } + } catch (e, s) { + _logger.severe("failed to verify recovery key", e, s); + await dialog.hide(); + const String errMessage = + "The recovery key you entered is not valid. Please make sure it " + "contains 24 words, and check the spelling of each.\n\nIf you " + "entered an older recovery code, make sure it is 64 characters long, and check each of them."; + final result = await showChoiceDialog( + context, + "Invalid key", + errMessage, + firstAction: "Try again", + secondAction: "View recovery key", + ); + if (result == DialogUserChoice.secondChoice) { + await _onViewRecoveryKeyClick(); + } + } + } + + Future _onViewRecoveryKeyClick() async { + final hasAuthenticated = + await LocalAuthenticationService.instance.requestLocalAuthentication( + context, + "Please authenticate to view your recovery key", + ); + if (hasAuthenticated) { + String recoveryKey; + try { + recoveryKey = Sodium.bin2hex(Configuration.instance.getRecoveryKey()); + routeToPage( + context, + RecoveryKeyPage( + recoveryKey, + "OK", + showAppBar: true, + onDone: () { + Navigator.of(context).pop(); + }, + ), + ); + } catch (e) { + showGenericErrorDialog(context); + return; + } + } + } + + @override + Widget build(BuildContext context) { + final enteTheme = Theme.of(context).colorScheme.enteTheme; + return Scaffold( + appBar: AppBar( + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + color: Theme.of(context).iconTheme.color, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: constraints.maxWidth, + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + children: [ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: Text( + 'Verify recovery key', + style: enteTheme.textTheme.h3Bold, + textAlign: TextAlign.left, + ), + ), + const SizedBox(height: 18), + Text( + "If you forget your password, your recovery key is the " + "only way to recover your photos.\n\nPlease verify that " + "you have safely backed up your 24 word recovery key by re-entering it.", + style: enteTheme.textTheme.small + .copyWith(color: enteTheme.colorScheme.textMuted), + ), + const SizedBox(height: 12), + TextFormField( + decoration: InputDecoration( + filled: true, + hintText: "Enter your recovery key", + contentPadding: const EdgeInsets.all(20), + border: UnderlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(6), + ), + ), + style: const TextStyle( + fontSize: 14, + fontFeatures: [FontFeature.tabularFigures()], + ), + controller: _recoveryKey, + autofocus: false, + autocorrect: false, + keyboardType: TextInputType.multiline, + minLines: 4, + maxLines: null, + onChanged: (_) { + setState(() {}); + }, + ), + const SizedBox(height: 12), + Text( + "If you saved the recovery key from older app versions, you might have a 64 character recovery code instead of 24 words. You can enter that too.", + style: enteTheme.textTheme.mini + .copyWith(color: enteTheme.colorScheme.textMuted), + ), + const SizedBox(height: 8), + Expanded( + child: Container( + alignment: Alignment.bottomCenter, + width: double.infinity, + padding: const EdgeInsets.fromLTRB(0, 12, 0, 40), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + GradientButton( + onTap: _verifyRecoveryKey, + text: "Verify", + iconData: Icons.shield_outlined, + ), + const SizedBox(height: 8), + ], + ), + ), + ), + const SizedBox(height: 20) + ], + ), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/ui/code_widget.dart b/lib/ui/code_widget.dart new file mode 100644 index 000000000..2a3494d13 --- /dev/null +++ b/lib/ui/code_widget.dart @@ -0,0 +1,208 @@ +import 'dart:async'; + +import 'package:clipboard/clipboard.dart'; +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/utils/toast_util.dart'; +import 'package:ente_auth/utils/totp_util.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_animation_progress_bar/flutter_animation_progress_bar.dart'; +// import 'package:flutter_animation_progress_bar/flutter_animation_progress_bar.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +class CodeWidget extends StatefulWidget { + final Code code; + + const CodeWidget(this.code, {Key? key}) : super(key: key); + + @override + State createState() => _CodeWidgetState(); +} + +class _CodeWidgetState extends State { + Timer? _everySecondTimer; + int _timeRemaining = 30; + + @override + void initState() { + super.initState(); + _updateTimeRemaining(); + _everySecondTimer = Timer.periodic(const Duration(seconds: 1), (Timer t) { + setState(() { + _updateTimeRemaining(); + }); + }); + } + + void _updateTimeRemaining() { + _timeRemaining = + widget.code.period - (DateTime.now().second % widget.code.period); + } + + @override + void dispose() { + _everySecondTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Slidable( + key: ValueKey(widget.code.hashCode), + endActionPane: ActionPane( + motion: const ScrollMotion(), + children: [ + SlidableAction( + onPressed: _onDeletePressed, + backgroundColor: Colors.grey.withOpacity(0.1), + borderRadius: const BorderRadius.all(Radius.circular(12.0)), + foregroundColor: const Color(0xFFFE4A49), + icon: Icons.delete, + label: 'Delete', + ), + ], + ), + child: InkWell( + onTap: () { + FlutterClipboard.copy(_getTotp()) + .then((value) => showToast(context, "Copied to clipboard")); + }, + child: SizedBox( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FAProgressBar( + currentValue: _timeRemaining / widget.code.period * 100, + size: 4, + animatedDuration: const Duration(milliseconds: 200), + progressColor: Colors.orange, + changeColorValue: 40, + changeProgressColor: Colors.green, + ), + const SizedBox( + height: 10, + ), + Padding( + padding: const EdgeInsets.only(left: 16, right: 16), + child: Text( + widget.code.issuer, + style: Theme.of(context).textTheme.headline6, + ), + ), + Container( + padding: const EdgeInsets.only(right: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + "next", + style: Theme.of(context).textTheme.caption, + ), + ], + ), + ), + Container( + padding: const EdgeInsets.only(left: 16, right: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + child: Text( + _getTotp(), + style: const TextStyle(fontSize: 24), + ), + ), + Text( + _getNextTotp(), + style: const TextStyle( + fontSize: 24, + color: Colors.grey, + ), + ), + ], + ), + ), + const SizedBox( + height: 32, + ), + ], + ), + ), + ), + ); + } + + void _onDeletePressed(_) { + final AlertDialog alert = AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + title: Text( + "Delete code?", + style: Theme.of(context).textTheme.headline6, + ), + content: const Text( + "Are you sure you want to delete this code? This action is irreversible.", + ), + actions: [ + TextButton( + child: const Text( + "Delete", + style: TextStyle( + color: Colors.red, + ), + ), + onPressed: () { + CodeStore.instance.removeCode(widget.code); + }, + ), + TextButton( + child: Text( + "Cancel", + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop('dialog'); + }, + ), + ], + ); + + showDialog( + context: context, + builder: (BuildContext context) { + return alert; + }, + barrierColor: Colors.black12, + ); + } + + String _getTotp() { + try { + return getTotp(widget.code); + } catch (e) { + return "Error"; + } + } + + String _getNextTotp() { + try { + return getNextTotp(widget.code); + } catch (e) { + return "Error"; + } + } + + Color _getProgressColor() { + final progress = _timeRemaining / widget.code.period; + if (progress > 0.6) { + return Colors.green; + } else if (progress > 0.4) { + return Colors.yellow; + } else if (progress > 2) { + return Colors.orange; + } + return Colors.red; + } +} diff --git a/lib/ui/common/DividerWithPadding.dart b/lib/ui/common/DividerWithPadding.dart new file mode 100644 index 000000000..8be210db2 --- /dev/null +++ b/lib/ui/common/DividerWithPadding.dart @@ -0,0 +1,25 @@ +// @dart=2.9 + +import 'package:flutter/material.dart'; + +class DividerWithPadding extends StatelessWidget { + final double left, top, right, bottom, thinckness; + const DividerWithPadding({ + Key key, + this.left = 0, + this.top = 0, + this.right = 0, + this.bottom = 0, + this.thinckness = 0.5, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.fromLTRB(left, top, right, bottom), + child: Divider( + thickness: thinckness, + ), + ); + } +} diff --git a/lib/ui/common/bottom_shadow.dart b/lib/ui/common/bottom_shadow.dart new file mode 100644 index 000000000..08dd69f23 --- /dev/null +++ b/lib/ui/common/bottom_shadow.dart @@ -0,0 +1,28 @@ +// @dart=2.9 + +import 'package:flutter/material.dart'; + +class BottomShadowWidget extends StatelessWidget { + final double offsetDy; + final Color shadowColor; + const BottomShadowWidget({this.offsetDy = 28, this.shadowColor, Key key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + height: 8, + decoration: BoxDecoration( + color: Colors.transparent, + boxShadow: [ + BoxShadow( + color: shadowColor ?? Theme.of(context).backgroundColor, + spreadRadius: 42, + blurRadius: 42, + offset: Offset(0, offsetDy), // changes position of shadow + ), + ], + ), + ); + } +} diff --git a/lib/ui/common/dialogs.dart b/lib/ui/common/dialogs.dart new file mode 100644 index 000000000..9c1e38ef9 --- /dev/null +++ b/lib/ui/common/dialogs.dart @@ -0,0 +1,79 @@ +// @dart=2.9 + +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +enum DialogUserChoice { firstChoice, secondChoice } + +enum ActionType { + confirm, + critical, +} + +// if dialog is dismissed by tapping outside, this will return null +Future showChoiceDialog( + BuildContext context, + String title, + String content, { + String firstAction = 'Ok', + Color firstActionColor, + String secondAction = 'Cancel', + Color secondActionColor, + ActionType actionType = ActionType.confirm, +}) { + final AlertDialog alert = AlertDialog( + title: Text( + title, + style: TextStyle( + color: actionType == ActionType.critical + ? Colors.red + : Theme.of(context).colorScheme.primary, + ), + ), + content: Text( + content, + style: const TextStyle( + height: 1.4, + ), + ), + actions: [ + TextButton( + child: Text( + firstAction, + style: TextStyle( + color: firstActionColor ?? + (actionType == ActionType.critical + ? Colors.red + : Theme.of(context).colorScheme.onSurface), + ), + ), + onPressed: () { + Navigator.of(context, rootNavigator: true) + .pop(DialogUserChoice.firstChoice); + }, + ), + TextButton( + child: Text( + secondAction, + style: TextStyle( + color: secondActionColor ?? + Theme.of(context).colorScheme.alternativeColor, + ), + ), + onPressed: () { + Navigator.of(context, rootNavigator: true) + .pop(DialogUserChoice.secondChoice); + }, + ), + ], + ); + + return showDialog( + context: context, + builder: (BuildContext context) { + return alert; + }, + barrierColor: Colors.black87, + ); +} diff --git a/lib/ui/common/dynamic_fab.dart b/lib/ui/common/dynamic_fab.dart new file mode 100644 index 000000000..c38fbfb40 --- /dev/null +++ b/lib/ui/common/dynamic_fab.dart @@ -0,0 +1,91 @@ +// @dart=2.9 + +import 'dart:math' as math; + +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:flutter/material.dart'; + +class DynamicFAB extends StatelessWidget { + final bool isKeypadOpen; + final bool isFormValid; + final String buttonText; + final Function onPressedFunction; + + const DynamicFAB({ + Key key, + this.isKeypadOpen, + this.buttonText, + this.isFormValid, + this.onPressedFunction, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (isKeypadOpen) { + return Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Theme.of(context).backgroundColor, + spreadRadius: 200, + blurRadius: 100, + offset: const Offset(0, 230), + ) + ], + ), + width: double.infinity, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FloatingActionButton( + heroTag: 'FAB', + backgroundColor: + Theme.of(context).colorScheme.dynamicFABBackgroundColor, + foregroundColor: + Theme.of(context).colorScheme.dynamicFABTextColor, + onPressed: isFormValid + ? onPressedFunction + : () { + FocusScope.of(context).unfocus(); + }, + child: Transform.rotate( + angle: isFormValid ? 0 : math.pi / 2, + child: const Icon( + Icons.chevron_right, + size: 36, + ), + ), //keypad down here + ), + ], + ), + ); + } else { + return Container( + width: double.infinity, + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: OutlinedButton( + onPressed: isFormValid ? onPressedFunction : null, + child: Text(buttonText), + ), + ); + } + } +} + +class NoScalingAnimation extends FloatingActionButtonAnimator { + @override + Offset getOffset({Offset begin, Offset end, double progress}) { + return end; + } + + @override + Animation getRotationAnimation({Animation parent}) { + return Tween(begin: 1.0, end: 1.0).animate(parent); + } + + @override + Animation getScaleAnimation({Animation parent}) { + return Tween(begin: 1.0, end: 1.0).animate(parent); + } +} diff --git a/lib/ui/common/gradient_button.dart b/lib/ui/common/gradient_button.dart new file mode 100644 index 000000000..586172d90 --- /dev/null +++ b/lib/ui/common/gradient_button.dart @@ -0,0 +1,82 @@ +// @dart=2.9 + +import 'package:flutter/material.dart'; + +class GradientButton extends StatelessWidget { + final List linearGradientColors; + final Function onTap; + + // text is ignored if child is specified + final String text; + + // nullable + final IconData iconData; + + // padding between the text and icon + final double paddingValue; + + const GradientButton({ + Key key, + this.linearGradientColors = const [ + Color.fromARGB(255, 133, 44, 210), + Color.fromARGB(255, 187, 26, 93), + ], + this.onTap, + this.text = '', + this.iconData, + this.paddingValue = 0.0, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + Widget buttonContent; + if (iconData == null) { + buttonContent = Text( + text, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontFamily: 'Inter-SemiBold', + fontSize: 18, + ), + ); + } else { + buttonContent = Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + iconData, + size: 20, + color: Colors.white, + ), + const Padding(padding: EdgeInsets.symmetric(horizontal: 6)), + Text( + text, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontFamily: 'Inter-SemiBold', + fontSize: 18, + ), + ), + ], + ); + } + return InkWell( + onTap: onTap, + child: Container( + height: 56, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: const Alignment(0.1, -0.9), + end: const Alignment(-0.6, 0.9), + colors: linearGradientColors, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Center(child: buttonContent), + ), + ); + } +} diff --git a/lib/ui/common/linear_progress_dialog.dart b/lib/ui/common/linear_progress_dialog.dart new file mode 100644 index 000000000..b441336f0 --- /dev/null +++ b/lib/ui/common/linear_progress_dialog.dart @@ -0,0 +1,51 @@ +// @dart=2.9 + +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:flutter/material.dart'; + +class LinearProgressDialog extends StatefulWidget { + final String message; + + const LinearProgressDialog(this.message, {Key key}) : super(key: key); + + @override + LinearProgressDialogState createState() => LinearProgressDialogState(); +} + +class LinearProgressDialogState extends State { + double _progress; + + @override + void initState() { + _progress = 0; + super.initState(); + } + + void setProgress(double progress) { + setState(() { + _progress = progress; + }); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async => false, + child: AlertDialog( + title: Text( + widget.message, + style: const TextStyle( + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + content: LinearProgressIndicator( + value: _progress, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.alternativeColor, + ), + ), + ), + ); + } +} diff --git a/lib/ui/common/loading_widget.dart b/lib/ui/common/loading_widget.dart new file mode 100644 index 000000000..8c84dae5e --- /dev/null +++ b/lib/ui/common/loading_widget.dart @@ -0,0 +1,15 @@ +import 'package:flutter/cupertino.dart'; + +class EnteLoadingWidget extends StatelessWidget { + const EnteLoadingWidget({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: SizedBox.fromSize( + size: const Size.square(30), + child: const CupertinoActivityIndicator(), + ), + ); + } +} diff --git a/lib/ui/common/progress_dialog.dart b/lib/ui/common/progress_dialog.dart new file mode 100644 index 000000000..616d53f76 --- /dev/null +++ b/lib/ui/common/progress_dialog.dart @@ -0,0 +1,287 @@ +// @dart=2.9 + +import 'package:flutter/material.dart'; + +enum ProgressDialogType { normal, download } + +String _dialogMessage = "Loading..."; +double _progress = 0.0, _maxProgress = 100.0; + +Widget _customBody; + +TextAlign _textAlign = TextAlign.left; +Alignment _progressWidgetAlignment = Alignment.centerLeft; + +TextDirection _direction = TextDirection.ltr; + +bool _isShowing = false; +BuildContext _context, _dismissingContext; +ProgressDialogType _progressDialogType; +bool _barrierDismissible = true, _showLogs = false; +Color _barrierColor; + +TextStyle _progressTextStyle = const TextStyle( + color: Colors.black, + fontSize: 12.0, + fontWeight: FontWeight.w400, + ), + _messageStyle = const TextStyle( + color: Colors.black, + fontSize: 18.0, + fontWeight: FontWeight.w600, + ); + +double _dialogElevation = 8.0, _borderRadius = 8.0; +Color _backgroundColor = Colors.white; +Curve _insetAnimCurve = Curves.easeInOut; +EdgeInsets _dialogPadding = const EdgeInsets.all(8.0); + +Widget _progressWidget = Image.asset( + 'assets/double_ring_loading_io.gif', + package: 'progress_dialog', +); + +class ProgressDialog { + _Body _dialog; + + ProgressDialog( + BuildContext context, { + ProgressDialogType type, + bool isDismissible, + bool showLogs, + TextDirection textDirection, + Widget customBody, + Color barrierColor, + }) { + _context = context; + _progressDialogType = type ?? ProgressDialogType.normal; + _barrierDismissible = isDismissible ?? true; + _showLogs = showLogs ?? false; + _customBody = customBody; + _direction = textDirection ?? TextDirection.ltr; + _barrierColor = barrierColor ?? barrierColor; + } + + void style({ + Widget child, + double progress, + double maxProgress, + String message, + Widget progressWidget, + Color backgroundColor, + TextStyle progressTextStyle, + TextStyle messageTextStyle, + double elevation, + TextAlign textAlign, + double borderRadius, + Curve insetAnimCurve, + EdgeInsets padding, + Alignment progressWidgetAlignment, + }) { + if (_isShowing) return; + if (_progressDialogType == ProgressDialogType.download) { + _progress = progress ?? _progress; + } + + _dialogMessage = message ?? _dialogMessage; + _maxProgress = maxProgress ?? _maxProgress; + _progressWidget = progressWidget ?? _progressWidget; + _backgroundColor = backgroundColor ?? _backgroundColor; + _messageStyle = messageTextStyle ?? _messageStyle; + _progressTextStyle = progressTextStyle ?? _progressTextStyle; + _dialogElevation = elevation ?? _dialogElevation; + _borderRadius = borderRadius ?? _borderRadius; + _insetAnimCurve = insetAnimCurve ?? _insetAnimCurve; + _textAlign = textAlign ?? _textAlign; + _progressWidget = child ?? _progressWidget; + _dialogPadding = padding ?? _dialogPadding; + _progressWidgetAlignment = + progressWidgetAlignment ?? _progressWidgetAlignment; + } + + void update({ + double progress, + double maxProgress, + String message, + Widget progressWidget, + TextStyle progressTextStyle, + TextStyle messageTextStyle, + }) { + if (_progressDialogType == ProgressDialogType.download) { + _progress = progress ?? _progress; + } + + _dialogMessage = message ?? _dialogMessage; + _maxProgress = maxProgress ?? _maxProgress; + _progressWidget = progressWidget ?? _progressWidget; + _messageStyle = messageTextStyle ?? _messageStyle; + _progressTextStyle = progressTextStyle ?? _progressTextStyle; + + if (_isShowing) _dialog.update(); + } + + bool isShowing() { + return _isShowing; + } + + Future hide() async { + try { + if (_isShowing) { + _isShowing = false; + Navigator.of(_dismissingContext).pop(); + if (_showLogs) debugPrint('ProgressDialog dismissed'); + return Future.value(true); + } else { + if (_showLogs) debugPrint('ProgressDialog already dismissed'); + return Future.value(false); + } + } catch (err) { + debugPrint('Seems there is an issue hiding dialog'); + debugPrint(err.toString()); + return Future.value(false); + } + } + + Future show() async { + try { + if (!_isShowing) { + _dialog = _Body(); + showDialog( + context: _context, + barrierDismissible: _barrierDismissible, + barrierColor: _barrierColor, + builder: (BuildContext context) { + _dismissingContext = context; + return WillPopScope( + onWillPop: () async => _barrierDismissible, + child: Dialog( + backgroundColor: _backgroundColor, + insetAnimationCurve: _insetAnimCurve, + insetAnimationDuration: const Duration(milliseconds: 100), + elevation: _dialogElevation, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.all(Radius.circular(_borderRadius)), + ), + child: _dialog, + ), + ); + }, + ); + // Delaying the function for 200 milliseconds + // [Default transitionDuration of DialogRoute] + await Future.delayed(const Duration(milliseconds: 200)); + if (_showLogs) debugPrint('ProgressDialog shown'); + _isShowing = true; + return true; + } else { + if (_showLogs) debugPrint("ProgressDialog already shown/showing"); + return false; + } + } catch (err) { + _isShowing = false; + debugPrint('Exception while showing the dialog'); + debugPrint(err.toString()); + return false; + } + } +} + +// ignore: must_be_immutable +class _Body extends StatefulWidget { + final _BodyState _dialog = _BodyState(); + + update() { + _dialog.update(); + } + + @override + State createState() { + return _dialog; + } +} + +class _BodyState extends State<_Body> { + update() { + setState(() {}); + } + + @override + void dispose() { + _isShowing = false; + if (_showLogs) debugPrint('ProgressDialog dismissed by back button'); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final loader = Align( + alignment: _progressWidgetAlignment, + child: SizedBox( + width: 60.0, + height: 60.0, + child: _progressWidget, + ), + ); + + final text = Expanded( + child: _progressDialogType == ProgressDialogType.normal + ? Text( + _dialogMessage, + textAlign: _textAlign, + style: _messageStyle, + textDirection: _direction, + ) + : Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8.0), + Row( + children: [ + Expanded( + child: Text( + _dialogMessage, + style: _messageStyle, + textDirection: _direction, + ), + ), + ], + ), + const SizedBox(height: 4.0), + Align( + alignment: Alignment.bottomRight, + child: Text( + "$_progress/$_maxProgress", + style: _progressTextStyle, + textDirection: _direction, + ), + ), + ], + ), + ), + ); + + return _customBody ?? + Container( + padding: _dialogPadding, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // row body + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8.0), + _direction == TextDirection.ltr ? loader : text, + const SizedBox(width: 8.0), + _direction == TextDirection.rtl ? loader : text, + const SizedBox(width: 8.0) + ], + ), + ], + ), + ); + } +} diff --git a/lib/ui/common/rename_dialog.dart b/lib/ui/common/rename_dialog.dart new file mode 100644 index 000000000..cfea32436 --- /dev/null +++ b/lib/ui/common/rename_dialog.dart @@ -0,0 +1,99 @@ +// @dart=2.9 + +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:flutter/material.dart'; + +class RenameDialog extends StatefulWidget { + final String name; + final String type; + final int maxLength; + + const RenameDialog(this.name, this.type, {Key key, this.maxLength = 100}) + : super(key: key); + + @override + State createState() => _RenameDialogState(); +} + +class _RenameDialogState extends State { + String _newName; + + @override + void initState() { + super.initState(); + _newName = widget.name; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text("Enter a new name"), + content: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + decoration: InputDecoration( + hintText: '${widget.type} name', + hintStyle: const TextStyle( + color: Colors.white30, + ), + contentPadding: const EdgeInsets.all(12), + ), + onChanged: (value) { + setState(() { + _newName = value; + }); + }, + autocorrect: false, + keyboardType: TextInputType.text, + initialValue: _newName, + autofocus: true, + ), + ], + ), + ), + actions: [ + TextButton( + child: const Text( + "Cancel", + style: TextStyle( + color: Colors.redAccent, + ), + ), + onPressed: () { + Navigator.of(context).pop(null); + }, + ), + TextButton( + child: Text( + "Rename", + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + onPressed: () { + if (_newName.trim().isEmpty) { + showErrorDialog( + context, + "Empty name", + "${widget.type} name cannot be empty", + ); + return; + } + if (_newName.trim().length > widget.maxLength) { + showErrorDialog( + context, + "Name too large", + "${widget.type} name should be less than ${widget.maxLength} characters", + ); + return; + } + Navigator.of(context).pop(_newName.trim()); + }, + ), + ], + ); + } +} diff --git a/lib/ui/common/report_bug.dart b/lib/ui/common/report_bug.dart new file mode 100644 index 000000000..f4eca1274 --- /dev/null +++ b/lib/ui/common/report_bug.dart @@ -0,0 +1,32 @@ +import 'package:ente_auth/utils/email_util.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +PopupMenuButton reportBugPopupMenu(BuildContext context) { + return PopupMenuButton( + itemBuilder: (context) { + final List items = []; + items.add( + PopupMenuItem( + value: 1, + child: Row( + children: const [ + Text("Contact support"), + ], + ), + ), + ); + return items; + }, + onSelected: (value) async { + if (value == 1) { + await sendLogs( + context, + "Contact support", + "support@ente.io", + postShare: () {}, + ); + } + }, + ); +} diff --git a/lib/ui/common/web_page.dart b/lib/ui/common/web_page.dart new file mode 100644 index 000000000..67a731031 --- /dev/null +++ b/lib/ui/common/web_page.dart @@ -0,0 +1,45 @@ +// @dart=2.9 + +import 'package:ente_auth/ui/common/loading_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; + +class WebPage extends StatefulWidget { + final String title; + final String url; + + const WebPage(this.title, this.url, {Key key}) : super(key: key); + + @override + State createState() => _WebPageState(); +} + +class _WebPageState extends State { + bool _hasLoadedPage = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + // force dark theme for appBar till website/family plans add supports for light theme + backgroundColor: const Color.fromRGBO(10, 20, 20, 1.0), + foregroundColor: Colors.white, + iconTheme: const IconThemeData(color: Colors.white), + title: Text(widget.title), + actions: [_hasLoadedPage ? Container() : const EnteLoadingWidget()], + ), + backgroundColor: Colors.black, + body: InAppWebView( + initialUrlRequest: URLRequest(url: Uri.parse(widget.url)), + initialOptions: InAppWebViewGroupOptions( + crossPlatform: InAppWebViewOptions(transparentBackground: true), + ), + onLoadStop: (c, url) { + setState(() { + _hasLoadedPage = true; + }); + }, + ), + ); + } +} diff --git a/lib/ui/components/captioned_text_widget.dart b/lib/ui/components/captioned_text_widget.dart new file mode 100644 index 000000000..77a161bde --- /dev/null +++ b/lib/ui/components/captioned_text_widget.dart @@ -0,0 +1,57 @@ +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:flutter/material.dart'; + +class CaptionedTextWidget extends StatelessWidget { + final String title; + final String? subTitle; + final TextStyle? textStyle; + final bool makeTextBold; + final Color? textColor; + const CaptionedTextWidget({ + required this.title, + this.subTitle, + this.textStyle, + this.makeTextBold = false, + this.textColor, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final enteColorScheme = Theme.of(context).colorScheme.enteTheme.colorScheme; + final enteTextTheme = Theme.of(context).colorScheme.enteTheme.textTheme; + + return Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 2), + child: Row( + children: [ + Flexible( + child: RichText( + text: TextSpan( + style: textStyle ?? + (makeTextBold + ? enteTextTheme.bodyBold.copyWith(color: textColor) + : enteTextTheme.body.copyWith(color: textColor)), + children: [ + TextSpan( + text: title, + ), + subTitle != null + ? TextSpan( + text: ' \u2022 $subTitle', + style: enteTextTheme.small.copyWith( + color: enteColorScheme.textMuted, + ), + ) + : const TextSpan(text: ''), + ], + ), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/ui/components/expandable_menu_item_widget.dart b/lib/ui/components/expandable_menu_item_widget.dart new file mode 100644 index 000000000..a94357c8f --- /dev/null +++ b/lib/ui/components/expandable_menu_item_widget.dart @@ -0,0 +1,79 @@ +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/ui/components/captioned_text_widget.dart'; +import 'package:ente_auth/ui/components/menu_item_widget.dart'; +// ignore: import_of_legacy_library_into_null_safe +import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:expandable/expandable.dart'; +import 'package:flutter/material.dart'; + +class ExpandableMenuItemWidget extends StatefulWidget { + final String title; + final Widget selectionOptionsWidget; + final IconData leadingIcon; + const ExpandableMenuItemWidget({ + required this.title, + required this.selectionOptionsWidget, + required this.leadingIcon, + Key? key, + }) : super(key: key); + + @override + State createState() => + _ExpandableMenuItemWidgetState(); +} + +class _ExpandableMenuItemWidgetState extends State { + final expandableController = ExpandableController(initialExpanded: false); + @override + void initState() { + expandableController.addListener(() { + setState(() {}); + }); + super.initState(); + } + + @override + void dispose() { + expandableController.removeListener(() {}); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final enteColorScheme = Theme.of(context).colorScheme.enteTheme.colorScheme; + final backgroundColor = + MediaQuery.of(context).platformBrightness == Brightness.light + ? enteColorScheme.backgroundElevated2 + : enteColorScheme.backgroundElevated; + return AnimatedContainer( + curve: Curves.ease, + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: expandableController.value ? backgroundColor : null, + borderRadius: BorderRadius.circular(4), + ), + child: ExpandableNotifier( + controller: expandableController, + child: ScrollOnExpand( + child: ExpandablePanel( + header: MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: widget.title, + makeTextBold: true, + ), + isExpandable: true, + leadingIcon: widget.leadingIcon, + trailingIcon: Icons.expand_more, + menuItemColor: enteColorScheme.fillFaint, + expandableController: expandableController, + ), + collapsed: const SizedBox.shrink(), + expanded: widget.selectionOptionsWidget, + theme: getExpandableTheme(context), + controller: expandableController, + ), + ), + ), + ); + } +} diff --git a/lib/ui/components/home_header_widget.dart b/lib/ui/components/home_header_widget.dart new file mode 100644 index 000000000..268084eef --- /dev/null +++ b/lib/ui/components/home_header_widget.dart @@ -0,0 +1,44 @@ +import 'dart:ui'; + +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/events/opened_settings_event.dart'; +import 'package:flutter/material.dart'; + +class HomeHeaderWidget extends StatefulWidget { + final Widget centerWidget; + const HomeHeaderWidget({required this.centerWidget, Key? key}) + : super(key: key); + + @override + State createState() => _HomeHeaderWidgetState(); +} + +class _HomeHeaderWidgetState extends State { + @override + Widget build(BuildContext context) { + final hasNotch = window.viewPadding.top > 65; + return Padding( + padding: EdgeInsets.fromLTRB(4, hasNotch ? 4 : 8, 4, 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + visualDensity: const VisualDensity(horizontal: -2, vertical: -2), + onPressed: () { + Scaffold.of(context).openDrawer(); + Bus.instance.fire(OpenedSettingsEvent()); + }, + splashColor: Colors.transparent, + icon: const Icon( + Icons.menu_outlined, + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: widget.centerWidget, + ), + ], + ), + ); + } +} diff --git a/lib/ui/components/menu_item_widget.dart b/lib/ui/components/menu_item_widget.dart new file mode 100644 index 000000000..bea3b2cf6 --- /dev/null +++ b/lib/ui/components/menu_item_widget.dart @@ -0,0 +1,176 @@ +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:expandable/expandable.dart'; +import 'package:flutter/material.dart'; + +class MenuItemWidget extends StatefulWidget { + final Widget captionedTextWidget; + final bool isExpandable; +// leading icon can be passed without specifing size of icon, this component sets size to 20x20 irrespective of passed icon's size + final IconData? leadingIcon; + final Color? leadingIconColor; +// trailing icon can be passed without size as default size set by flutter is what this component expects + final IconData? trailingIcon; + final Widget? trailingSwitch; + final bool trailingIconIsMuted; + final VoidCallback? onTap; + final VoidCallback? onDoubleTap; + final Color? menuItemColor; + final bool alignCaptionedTextToLeft; + final double borderRadius; + final Color? pressedColor; + final ExpandableController? expandableController; + const MenuItemWidget({ + required this.captionedTextWidget, + this.isExpandable = false, + this.leadingIcon, + this.leadingIconColor, + this.trailingIcon, + this.trailingSwitch, + this.trailingIconIsMuted = false, + this.onTap, + this.onDoubleTap, + this.menuItemColor, + this.alignCaptionedTextToLeft = false, + this.borderRadius = 4.0, + this.pressedColor, + this.expandableController, + Key? key, + }) : super(key: key); + + @override + State createState() => _MenuItemWidgetState(); +} + +class _MenuItemWidgetState extends State { + Color? menuItemColor; + @override + void initState() { + menuItemColor = widget.menuItemColor; + if (widget.expandableController != null) { + widget.expandableController!.addListener(() { + setState(() {}); + }); + } + super.initState(); + } + + @override + void didChangeDependencies() { + menuItemColor = widget.menuItemColor; + super.didChangeDependencies(); + } + + @override + void dispose() { + if (widget.expandableController != null) { + widget.expandableController!.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.isExpandable + ? menuItemWidget(context) + : GestureDetector( + onTap: widget.onTap, + onDoubleTap: widget.onDoubleTap, + onTapDown: _onTapDown, + onTapUp: _onTapUp, + onTapCancel: _onCancel, + child: menuItemWidget(context), + ); + } + + Widget menuItemWidget(BuildContext context) { + final enteColorScheme = Theme.of(context).colorScheme.enteTheme.colorScheme; + final borderRadius = Radius.circular(widget.borderRadius); + final isExpanded = widget.expandableController?.value; + final bottomBorderRadius = isExpanded != null && isExpanded + ? const Radius.circular(0) + : borderRadius; + return AnimatedContainer( + duration: const Duration(milliseconds: 20), + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: borderRadius, + topRight: borderRadius, + bottomLeft: bottomBorderRadius, + bottomRight: bottomBorderRadius, + ), + color: menuItemColor, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + widget.alignCaptionedTextToLeft && widget.leadingIcon == null + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.only(right: 10), + child: SizedBox( + height: 20, + width: 20, + child: widget.leadingIcon == null + ? const SizedBox.shrink() + : FittedBox( + fit: BoxFit.contain, + child: Icon( + widget.leadingIcon, + color: widget.leadingIconColor, + ), + ), + ), + ), + widget.captionedTextWidget, + widget.expandableController != null + ? AnimatedOpacity( + duration: const Duration(milliseconds: 100), + curve: Curves.easeInOut, + opacity: isExpanded! ? 0 : 1, + child: AnimatedSwitcher( + transitionBuilder: (child, animation) { + return ScaleTransition(scale: animation, child: child); + }, + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + child: isExpanded + ? const SizedBox.shrink() + : Icon(widget.trailingIcon), + ), + ) + : widget.trailingIcon != null + ? Icon( + widget.trailingIcon, + color: widget.trailingIconIsMuted + ? enteColorScheme.strokeMuted + : null, + ) + : widget.trailingSwitch ?? const SizedBox.shrink(), + ], + ), + ); + } + + void _onTapDown(details) { + setState(() { + menuItemColor = widget.pressedColor; + }); + } + + void _onTapUp(details) { + Future.delayed( + const Duration(milliseconds: 100), + () => setState(() { + menuItemColor = widget.menuItemColor; + }), + ); + } + + void _onCancel() { + setState(() { + menuItemColor = widget.menuItemColor; + }); + } +} diff --git a/lib/ui/components/notification_warning_widget.dart b/lib/ui/components/notification_warning_widget.dart new file mode 100644 index 000000000..dc15fd5d0 --- /dev/null +++ b/lib/ui/components/notification_warning_widget.dart @@ -0,0 +1,79 @@ +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/theme/colors.dart'; +import 'package:ente_auth/theme/text_style.dart'; +import 'package:flutter/material.dart'; + +class NotificationWarningWidget extends StatelessWidget { + final IconData warningIcon; + final IconData actionIcon; + final String text; + final GestureTapCallback onTap; + + const NotificationWarningWidget({ + Key? key, + required this.warningIcon, + required this.actionIcon, + required this.text, + required this.onTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: GestureDetector( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12), + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(8), + ), + boxShadow: Theme.of(context).colorScheme.enteTheme.shadowMenu, + color: warning500, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Icon( + warningIcon, + size: 36, + color: Colors.white, + ), + const SizedBox(width: 12), + Flexible( + child: Text( + text, + style: darkTextTheme.bodyBold, + textAlign: TextAlign.left, + ), + ), + const SizedBox(width: 12), + ClipOval( + child: Material( + color: fillFaintDark, + child: InkWell( + splashColor: Colors.red, // Splash color + onTap: onTap, + child: SizedBox( + width: 40, + height: 40, + child: Icon( + actionIcon, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/components/toggle_switch_widget.dart b/lib/ui/components/toggle_switch_widget.dart new file mode 100644 index 000000000..7ec95dfb0 --- /dev/null +++ b/lib/ui/components/toggle_switch_widget.dart @@ -0,0 +1,40 @@ +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:flutter/material.dart'; + +typedef OnChangedCallBack = void Function(bool); + +class ToggleSwitchWidget extends StatefulWidget { + final bool value; + final OnChangedCallBack onChanged; + const ToggleSwitchWidget({ + required this.value, + required this.onChanged, + Key? key, + }) : super(key: key); + + @override + State createState() => _ToggleSwitchWidgetState(); +} + +class _ToggleSwitchWidgetState extends State { + @override + Widget build(BuildContext context) { + final enteColorScheme = Theme.of(context).colorScheme.enteTheme.colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: SizedBox( + height: 30, + child: FittedBox( + fit: BoxFit.contain, + child: Switch.adaptive( + activeColor: enteColorScheme.primary400, + inactiveTrackColor: enteColorScheme.fillMuted, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: widget.value, + onChanged: widget.onChanged, + ), + ), + ), + ); + } +} diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart new file mode 100644 index 000000000..976266f04 --- /dev/null +++ b/lib/ui/home_page.dart @@ -0,0 +1,226 @@ +// ignore_for_file: import_of_legacy_library_into_null_safe + +import 'dart:async'; +import 'dart:io'; + +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/events/codes_updated_event.dart'; +import "package:ente_auth/l10n/l10n.dart"; +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart'; +import 'package:ente_auth/services/user_service.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/ui/code_widget.dart'; +import 'package:ente_auth/ui/common/loading_widget.dart'; +import 'package:ente_auth/ui/scanner_page.dart'; +import 'package:ente_auth/ui/settings_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_speed_dial/flutter_speed_dial.dart'; +import 'package:move_to_background/move_to_background.dart'; + +class HomePage extends StatefulWidget { + const HomePage({Key? key}) : super(key: key); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + static final _settingsPage = SettingsPage( + emailNotifier: UserService.instance.emailValueNotifier, + ); + bool _hasLoaded = false; + bool _isSettingsOpen = false; + List _codes = []; + StreamSubscription? _streamSubscription; + + @override + void initState() { + super.initState(); + _loadCodes(); + _streamSubscription = Bus.instance.on().listen((event) { + _loadCodes(); + setState(() {}); + }); + } + + void _loadCodes() { + CodeStore.instance.getAllCodes().then((codes) { + _codes = codes; + _hasLoaded = true; + setState(() {}); + }); + } + + @override + void dispose() { + _streamSubscription?.cancel(); + super.dispose(); + } + + Future _redirectToScannerPage() async { + final Code? code = await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const ScannerPage(); + }, + ), + ); + if (code != null) { + CodeStore.instance.addCode(code); + } + } + + Future _redirectToManualEntryPage() async { + final Code? code = await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return SetupEnterSecretKeyPage(); + }, + ), + ); + if (code != null) { + CodeStore.instance.addCode(code); + } + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + if (_isSettingsOpen) { + Navigator.pop(context); + return false; + } + if (Platform.isAndroid) { + MoveToBackground.moveTaskToBack(); + return false; + } else { + return true; + } + }, + child: Scaffold( + drawerEnableOpenDragGesture: true, + drawer: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 428), + child: Drawer( + width: double.infinity, + child: _settingsPage, + ), + ), + onDrawerChanged: (isOpened) => _isSettingsOpen = isOpened, + body: SafeArea( + bottom: false, + child: Builder( + builder: (context) { + return _getBody(); + }, + ), + ), + resizeToAvoidBottomInset: false, + appBar: AppBar( + title: const Text('ente Authenticator'), + ), + floatingActionButton: !_hasLoaded || _codes.isEmpty ? null : _getFab(), + ), + ); + } + + Widget _getBody() { + if (_hasLoaded) { + if (_codes.isEmpty) { + return _getEmptyState(); + } else { + return ListView.builder( + itemBuilder: ((context, index) { + return CodeWidget(_codes[index]); + }), + itemCount: _codes.length, + ); + } + } else { + return const EnteLoadingWidget(); + } + } + + Widget _getFab() { + return SpeedDial( + icon: Icons.add, + activeIcon: Icons.close, + spacing: 3, + childPadding: const EdgeInsets.all(5), + spaceBetweenChildren: 4, + tooltip: 'Add Code', + foregroundColor: Theme.of(context).colorScheme.background, + backgroundColor: Theme.of(context).colorScheme.inverseBackgroundColor, + overlayOpacity: 0.5, + overlayColor: Theme.of(context).colorScheme.background, + elevation: 8.0, + animationCurve: Curves.elasticInOut, + children: [ + SpeedDialChild( + child: const Icon(Icons.qr_code), + foregroundColor: Theme.of(context).colorScheme.background, + backgroundColor: Theme.of(context).colorScheme.inverseBackgroundColor, + label: 'Scan a QR Code', + onTap: _redirectToScannerPage, + ), + SpeedDialChild( + child: const Icon(Icons.keyboard), + foregroundColor: Theme.of(context).colorScheme.background, + backgroundColor: Theme.of(context).colorScheme.inverseBackgroundColor, + label: 'Enter details manually', + onTap: _redirectToManualEntryPage, + ), + ], + ); + } + + Widget _getEmptyState() { + final l10n = context.l10n; + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints.tightFor(height: 800, width: 450), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 40.0, horizontal: 40), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + children: [ + Image.asset( + "assets/wallet-front-gradient.png", + width: 200, + height: 200, + ), + Text( + l10n.setupFirstAccount, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headline4, + ), + const SizedBox(height: 64), + SizedBox( + width: 400, + child: OutlinedButton( + onPressed: _redirectToScannerPage, + child: Text(l10n.importScanQrCode), + ), + ), + const SizedBox(height: 18), + SizedBox( + width: 400, + child: OutlinedButton( + onPressed: _redirectToManualEntryPage, + child: Text(l10n.importEnterSetupKey), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/lifecycle_event_handler.dart b/lib/ui/lifecycle_event_handler.dart new file mode 100644 index 000000000..3f54c3438 --- /dev/null +++ b/lib/ui/lifecycle_event_handler.dart @@ -0,0 +1,32 @@ +// @dart=2.9 + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +class LifecycleEventHandler extends WidgetsBindingObserver { + final AsyncCallback resumeCallBack; + final AsyncCallback suspendingCallBack; + + LifecycleEventHandler({ + this.resumeCallBack, + this.suspendingCallBack, + }); + + @override + Future didChangeAppLifecycleState(AppLifecycleState state) async { + switch (state) { + case AppLifecycleState.resumed: + if (resumeCallBack != null) { + await resumeCallBack(); + } + break; + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + case AppLifecycleState.detached: + if (suspendingCallBack != null) { + await suspendingCallBack(); + } + break; + } + } +} diff --git a/lib/ui/payment/billing_questions_widget.dart b/lib/ui/payment/billing_questions_widget.dart new file mode 100644 index 000000000..46560d0fb --- /dev/null +++ b/lib/ui/payment/billing_questions_widget.dart @@ -0,0 +1,153 @@ +// @dart=2.9 + +import 'dart:convert'; + +import 'package:ente_auth/core/network.dart'; +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/ui/common/loading_widget.dart'; +import 'package:expansion_tile_card/expansion_tile_card.dart'; +import 'package:flutter/material.dart'; + +class BillingQuestionsWidget extends StatelessWidget { + const BillingQuestionsWidget({ + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: Network.instance + .getDio() + .get("https://static.ente.io/faq.json") + .then((response) { + final faqItems = []; + for (final item in response.data as List) { + faqItems.add(FaqItem.fromMap(item)); + } + return faqItems; + }), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + final faqs = []; + faqs.add( + const Padding( + padding: EdgeInsets.all(24), + child: Text( + "FAQs", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + for (final faq in snapshot.data) { + faqs.add(FaqWidget(faq: faq)); + } + faqs.add( + const Padding( + padding: EdgeInsets.all(16), + ), + ); + return SingleChildScrollView( + child: Column( + children: faqs, + ), + ); + } else { + return const EnteLoadingWidget(); + } + }, + ); + } +} + +class FaqWidget extends StatelessWidget { + const FaqWidget({ + Key key, + @required this.faq, + }) : super(key: key); + + final FaqItem faq; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(2), + child: ExpansionTileCard( + elevation: 0, + title: Text(faq.q), + expandedTextColor: Theme.of(context).colorScheme.alternativeColor, + baseColor: Theme.of(context).cardColor, + children: [ + Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + bottom: 12, + ), + child: Text( + faq.a, + style: const TextStyle( + height: 1.5, + ), + ), + ) + ], + ), + ); + } +} + +class FaqItem { + final String q; + final String a; + FaqItem({ + this.q, + this.a, + }); + + FaqItem copyWith({ + String q, + String a, + }) { + return FaqItem( + q: q ?? this.q, + a: a ?? this.a, + ); + } + + Map toMap() { + return { + 'q': q, + 'a': a, + }; + } + + factory FaqItem.fromMap(Map map) { + if (map == null) return null; + + return FaqItem( + q: map['q'], + a: map['a'], + ); + } + + String toJson() => json.encode(toMap()); + + factory FaqItem.fromJson(String source) => + FaqItem.fromMap(json.decode(source)); + + @override + String toString() => 'FaqItem(q: $q, a: $a)'; + + @override + bool operator ==(Object o) { + if (identical(this, o)) return true; + + return o is FaqItem && o.q == q && o.a == a; + } + + @override + int get hashCode => q.hashCode ^ a.hashCode; +} diff --git a/lib/ui/payment/child_subscription_widget.dart b/lib/ui/payment/child_subscription_widget.dart new file mode 100644 index 000000000..65cd5b830 --- /dev/null +++ b/lib/ui/payment/child_subscription_widget.dart @@ -0,0 +1,146 @@ +// @dart=2.9 + +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/models/user_details.dart'; +import 'package:ente_auth/services/user_service.dart'; +import 'package:ente_auth/ui/common/dialogs.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:flutter/material.dart'; + +class ChildSubscriptionWidget extends StatelessWidget { + const ChildSubscriptionWidget({ + Key key, + @required this.userDetails, + }) : super(key: key); + + final UserDetails userDetails; + + @override + Widget build(BuildContext context) { + final String familyAdmin = userDetails.familyData.members + .firstWhere((element) => element.isAdmin) + .email; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Center( + child: Text( + "You are on a family plan!", + style: Theme.of(context).textTheme.bodyText1, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + children: [ + const TextSpan( + text: "Please contact ", + ), + TextSpan( + text: familyAdmin, + style: + const TextStyle(color: Color.fromRGBO(29, 185, 84, 1)), + ), + const TextSpan( + text: " to manage your subscription", + ), + ], + style: Theme.of(context).textTheme.bodyText1, + ), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + ), + Image.asset( + "assets/family_plan_leave.png", + height: 256, + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 0), + ), + InkWell( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: + const EdgeInsets.symmetric(vertical: 18, horizontal: 100), + backgroundColor: Colors.red[500], + ), + child: const Text( + "Leave Family", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: Colors.white, // same for both themes + ), + textAlign: TextAlign.center, + ), + onPressed: () async => {await _leaveFamilyPlan(context)}, + ), + ), + Expanded( + child: Align( + alignment: Alignment.bottomCenter, + child: RichText( + textAlign: TextAlign.center, + text: TextSpan( + children: [ + TextSpan( + text: "Please contact ", + style: Theme.of(context).textTheme.bodyText2, + ), + TextSpan( + text: "support@ente.io", + style: Theme.of(context).textTheme.bodyText2.copyWith( + color: const Color.fromRGBO(29, 185, 84, 1), + ), + ), + TextSpan( + text: " for help", + style: Theme.of(context).textTheme.bodyText2, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Future _leaveFamilyPlan(BuildContext context) async { + final choice = await showChoiceDialog( + context, + 'Leave family', + 'Are you sure that you want to leave the family plan?', + firstAction: 'No', + secondAction: 'Yes', + firstActionColor: Theme.of(context).colorScheme.alternativeColor, + secondActionColor: Theme.of(context).colorScheme.onSurface, + ); + if (choice != DialogUserChoice.secondChoice) { + return; + } + final dialog = createProgressDialog(context, "Please wait..."); + await dialog.show(); + try { + await UserService.instance.leaveFamilyPlan(); + dialog.hide(); + Navigator.of(context).pop(''); + } catch (e) { + dialog.hide(); + showGenericErrorDialog(context); + } + } +} diff --git a/lib/ui/payment/payment_web_page.dart b/lib/ui/payment/payment_web_page.dart new file mode 100644 index 000000000..07ded9f87 --- /dev/null +++ b/lib/ui/payment/payment_web_page.dart @@ -0,0 +1,266 @@ +// @dart=2.9 + +import 'dart:io'; + +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/models/subscription.dart'; +import 'package:ente_auth/services/billing_service.dart'; +import 'package:ente_auth/services/user_service.dart'; +import 'package:ente_auth/ui/common/loading_widget.dart'; +import 'package:ente_auth/ui/common/progress_dialog.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:logging/logging.dart'; + +class PaymentWebPage extends StatefulWidget { + final String planId; + final String actionType; + + const PaymentWebPage({Key key, this.planId, this.actionType}) + : super(key: key); + + @override + State createState() => _PaymentWebPageState(); +} + +class _PaymentWebPageState extends State { + final _logger = Logger("PaymentWebPageState"); + final UserService userService = UserService.instance; + final BillingService billingService = BillingService.instance; + final String basePaymentUrl = kWebPaymentBaseEndpoint; + ProgressDialog _dialog; + InAppWebViewController webView; + double progress = 0; + Uri initPaymentUrl; + + @override + void initState() { + userService.getPaymentToken().then((token) { + initPaymentUrl = _getPaymentUrl(token); + setState(() {}); + }); + if (Platform.isAndroid && kDebugMode) { + AndroidInAppWebViewController.setWebContentsDebuggingEnabled(true); + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + _dialog = createProgressDialog(context, "Please wait..."); + if (initPaymentUrl == null) { + return const EnteLoadingWidget(); + } + return WillPopScope( + onWillPop: () async => _buildPageExitWidget(context), + child: Scaffold( + appBar: AppBar( + title: const Text('Subscription'), + ), + body: Column( + children: [ + (progress != 1.0) + ? LinearProgressIndicator(value: progress) + : Container(), + Expanded( + child: InAppWebView( + initialUrlRequest: URLRequest(url: initPaymentUrl), + onProgressChanged: + (InAppWebViewController controller, int progress) { + setState(() { + this.progress = progress / 100; + }); + }, + initialOptions: InAppWebViewGroupOptions( + crossPlatform: InAppWebViewOptions( + useShouldOverrideUrlLoading: true, + ), + ), + shouldOverrideUrlLoading: (controller, navigationAction) async { + final loadingUri = navigationAction.request.url; + _logger.info("Loading url $loadingUri"); + // handle the payment response + if (_isPaymentActionComplete(loadingUri)) { + await _handlePaymentResponse(loadingUri); + return NavigationActionPolicy.CANCEL; + } + return NavigationActionPolicy.ALLOW; + }, + onConsoleMessage: (controller, consoleMessage) { + _logger.info(consoleMessage); + }, + onLoadStart: (controller, navigationAction) async { + if (!_dialog.isShowing()) { + await _dialog.show(); + } + }, + onLoadError: (controller, navigationAction, code, msg) async { + if (_dialog.isShowing()) { + await _dialog.hide(); + } + }, + onLoadHttpError: + (controller, navigationAction, code, msg) async { + _logger.info("onHttpError with $code and msg = $msg"); + }, + onLoadStop: (controller, navigationAction) async { + _logger.info("loadStart" + navigationAction.toString()); + if (_dialog.isShowing()) { + await _dialog.hide(); + } + }, + ), + ), + ].where((Object o) => o != null).toList(), + ), + ), + ); + } + + @override + void dispose() { + _dialog.hide(); + super.dispose(); + } + + Uri _getPaymentUrl(String paymentToken) { + final queryParameters = { + 'productID': widget.planId, + 'paymentToken': paymentToken, + 'action': widget.actionType, + 'redirectURL': kWebPaymentRedirectUrl, + }; + final tryParse = Uri.tryParse(kWebPaymentBaseEndpoint); + if (kDebugMode && kWebPaymentBaseEndpoint.startsWith("http://")) { + return Uri.http(tryParse.authority, tryParse.path, queryParameters); + } else { + return Uri.https(tryParse.authority, tryParse.path, queryParameters); + } + } + + // show dialog to handle accidental back press. + Future _buildPageExitWidget(BuildContext context) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Are you sure you want to exit?'), + actions: [ + TextButton( + child: const Text( + 'Yes', + style: TextStyle( + color: Colors.redAccent, + ), + ), + onPressed: () => Navigator.of(context).pop(true), + ), + TextButton( + child: Text( + 'No', + style: TextStyle( + color: Theme.of(context).colorScheme.alternativeColor, + ), + ), + onPressed: () => Navigator.of(context).pop(false), + ), + ], + ), + ); + } + + bool _isPaymentActionComplete(Uri loadingUri) { + return loadingUri.toString().startsWith(kWebPaymentRedirectUrl); + } + + Future _handlePaymentResponse(Uri uri) async { + final queryParams = uri.queryParameters; + final paymentStatus = uri.queryParameters['status'] ?? ''; + _logger.fine('handle payment response with status $paymentStatus'); + if (paymentStatus == 'success') { + await _handlePaymentSuccess(queryParams); + } else if (paymentStatus == 'fail') { + final reason = queryParams['reason'] ?? ''; + await _handlePaymentFailure(reason); + } else { + // should never reach here + _logger.severe("unexpected status", uri.toString()); + showGenericErrorDialog(context); + } + } + + Future _handlePaymentFailure(String reason) async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Text('Payment failed'), + content: Text("Unfortunately your payment failed due to $reason"), + actions: [ + TextButton( + child: const Text('Ok'), + onPressed: () { + Navigator.of(context).pop('dialog'); + }, + ), + ], + ), + ); + Navigator.of(context).pop(true); + } + + // return true if verifySubscription didn't throw any exceptions + Future _handlePaymentSuccess(Map queryParams) async { + final checkoutSessionID = queryParams['session_id'] ?? ''; + await _dialog.show(); + try { + final response = await billingService.verifySubscription( + widget.planId, + checkoutSessionID, + paymentProvider: stripe, + ); + await _dialog.hide(); + if (response != null) { + final content = widget.actionType == 'buy' + ? 'Your purchase was successful' + : 'Your subscription was updated successfully'; + await _showExitPageDialog(title: 'Thank you', content: content); + } else { + throw Exception("verifySubscription api failed"); + } + } catch (error) { + _logger.severe(error); + await _dialog.hide(); + await _showExitPageDialog( + title: 'Failed to verify payment status', + content: 'Please wait for sometime before retrying', + ); + } + } + + // warn the user to wait for sometime before trying another payment + Future _showExitPageDialog({String title, String content}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(content), + actions: [ + TextButton( + child: Text( + 'Ok', + style: TextStyle( + color: Theme.of(context).colorScheme.alternativeColor, + ), + ), + onPressed: () { + Navigator.of(context).pop('dialog'); + }, + ), + ], + ), + ).then((val) => Navigator.pop(context, true)); + } +} diff --git a/lib/ui/payment/skip_subscription_widget.dart b/lib/ui/payment/skip_subscription_widget.dart new file mode 100644 index 000000000..074761d3b --- /dev/null +++ b/lib/ui/payment/skip_subscription_widget.dart @@ -0,0 +1,51 @@ +// @dart=2.9 + +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/events/subscription_purchased_event.dart'; +import 'package:ente_auth/models/billing_plan.dart'; +import 'package:ente_auth/models/subscription.dart'; +import 'package:ente_auth/onboarding/view/onboarding_page.dart'; +import 'package:ente_auth/services/billing_service.dart'; +import 'package:flutter/material.dart'; + +class SkipSubscriptionWidget extends StatelessWidget { + const SkipSubscriptionWidget({ + Key key, + @required this.freePlan, + }) : super(key: key); + + final FreePlan freePlan; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + height: 64, + margin: const EdgeInsets.fromLTRB(0, 30, 0, 0), + padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), + child: OutlinedButton( + style: Theme.of(context).outlinedButtonTheme.style.copyWith( + textStyle: MaterialStateProperty.resolveWith( + (Set states) { + return Theme.of(context).textTheme.subtitle1; + }, + ), + ), + onPressed: () async { + Bus.instance.fire(SubscriptionPurchasedEvent()); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (BuildContext context) { + return const OnboardingPage(); + }, + ), + (route) => false, + ); + BillingService.instance + .verifySubscription(freeProductID, "", paymentProvider: "ente"); + }, + child: const Text("Continue on free plan"), + ), + ); + } +} diff --git a/lib/ui/payment/stripe_subscription_page.dart b/lib/ui/payment/stripe_subscription_page.dart new file mode 100644 index 000000000..e4e1290e1 --- /dev/null +++ b/lib/ui/payment/stripe_subscription_page.dart @@ -0,0 +1,571 @@ +// @dart=2.9 + +import 'dart:async'; + +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/models/billing_plan.dart'; +import 'package:ente_auth/models/subscription.dart'; +import 'package:ente_auth/models/user_details.dart'; +import 'package:ente_auth/services/billing_service.dart'; +import 'package:ente_auth/services/user_service.dart'; +import 'package:ente_auth/ui/common/bottom_shadow.dart'; +import 'package:ente_auth/ui/common/dialogs.dart'; +import 'package:ente_auth/ui/common/loading_widget.dart'; +import 'package:ente_auth/ui/common/progress_dialog.dart'; +import 'package:ente_auth/ui/common/web_page.dart'; +import 'package:ente_auth/ui/payment/child_subscription_widget.dart'; +import 'package:ente_auth/ui/payment/payment_web_page.dart'; +import 'package:ente_auth/ui/payment/skip_subscription_widget.dart'; +import 'package:ente_auth/ui/payment/subscription_common_widgets.dart'; +import 'package:ente_auth/ui/payment/subscription_plan_widget.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/toast_util.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:step_progress_indicator/step_progress_indicator.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class StripeSubscriptionPage extends StatefulWidget { + final bool isOnboarding; + + const StripeSubscriptionPage({ + this.isOnboarding = false, + Key key, + }) : super(key: key); + + @override + State createState() => _StripeSubscriptionPageState(); +} + +class _StripeSubscriptionPageState extends State { + final _logger = Logger("StripeSubscriptionPage"); + final _billingService = BillingService.instance; + final _userService = UserService.instance; + Subscription _currentSubscription; + ProgressDialog _dialog; + UserDetails _userDetails; + + // indicates if user's subscription plan is still active + bool _hasActiveSubscription; + FreePlan _freePlan; + List _plans = []; + bool _hasLoadedData = false; + bool _isLoading = false; + bool _isStripeSubscriber = false; + bool _showYearlyPlan = false; + + @override + void initState() { + super.initState(); + } + + Future _fetchSub() async { + return _userService + .getUserDetailsV2(memoryCount: false) + .then((userDetails) async { + _userDetails = userDetails; + _currentSubscription = userDetails.subscription; + _showYearlyPlan = _currentSubscription.isYearlyPlan(); + _hasActiveSubscription = _currentSubscription.isValid(); + _isStripeSubscriber = _currentSubscription.paymentProvider == stripe; + return _filterStripeForUI().then((value) { + _hasLoadedData = true; + setState(() {}); + }); + }); + } + + // _filterPlansForUI is used for initializing initState & plan toggle states + Future _filterStripeForUI() async { + final billingPlans = await _billingService.getBillingPlans(); + _freePlan = billingPlans.freePlan; + _plans = billingPlans.plans.where((plan) { + if (plan.stripeID == null || plan.stripeID.isEmpty) { + return false; + } + final isYearlyPlan = plan.period == 'year'; + return isYearlyPlan == _showYearlyPlan; + }).toList(); + setState(() {}); + } + + FutureOr onWebPaymentGoBack(dynamic value) async { + // refresh subscription + await _dialog.show(); + try { + await _fetchSub(); + } catch (e) { + showToast(context, "Failed to refresh subscription"); + } + await _dialog.hide(); + + // verify user has subscribed before redirecting to main page + if (widget.isOnboarding && + _currentSubscription != null && + _currentSubscription.isValid() && + _currentSubscription.productID != freeProductID) { + Navigator.of(context).popUntil((route) => route.isFirst); + } + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final appBar = PreferredSize( + preferredSize: const Size(double.infinity, 60), + child: Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Theme.of(context).backgroundColor, + blurRadius: 16, + offset: const Offset(0, 8), + ) + ], + ), + child: widget.isOnboarding + ? AppBar( + elevation: 0, + title: Hero( + tag: "subscription", + child: StepProgressIndicator( + totalSteps: 4, + currentStep: 4, + selectedColor: + Theme.of(context).colorScheme.alternativeColor, + roundedEdges: const Radius.circular(10), + unselectedColor: Theme.of(context) + .colorScheme + .stepProgressUnselectedColor, + ), + ), + ) + : AppBar( + elevation: 0, + title: const Text("Subscription"), + ), + ), + ); + return Scaffold( + appBar: appBar, + body: Stack( + alignment: Alignment.bottomCenter, + children: [ + _getBody(), + const BottomShadowWidget( + offsetDy: 40, + ) + ], + ), + ); + } + + Widget _getBody() { + if (!_isLoading) { + _isLoading = true; + _dialog = createProgressDialog(context, "Please wait..."); + _fetchSub(); + } + if (_hasLoadedData) { + if (_userDetails.isPartOfFamily() && !_userDetails.isFamilyAdmin()) { + return ChildSubscriptionWidget(userDetails: _userDetails); + } else { + return _buildPlans(); + } + } + return const EnteLoadingWidget(); + } + + Widget _buildPlans() { + final widgets = []; + + widgets.add( + SubscriptionHeaderWidget( + isOnboarding: widget.isOnboarding, + currentUsage: _userDetails.getFamilyOrPersonalUsage(), + ), + ); + + widgets.addAll([ + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: _getStripePlanWidgets(), + ), + const Padding(padding: EdgeInsets.all(4)), + ]); + + widgets.add(_showSubscriptionToggle()); + + if (_hasActiveSubscription) { + widgets.add(ValidityWidget(currentSubscription: _currentSubscription)); + } + + if (_currentSubscription.productID == freeProductID) { + if (widget.isOnboarding) { + widgets.add(SkipSubscriptionWidget(freePlan: _freePlan)); + } + widgets.add(const SubFaqWidget()); + } + + // only active subscription can be renewed/canceled + if (_hasActiveSubscription && _isStripeSubscriber) { + widgets.add(_stripeRenewOrCancelButton()); + } + + if (_currentSubscription.productID != freeProductID) { + widgets.addAll([ + Align( + alignment: Alignment.center, + child: GestureDetector( + onTap: () async { + final String paymentProvider = + _currentSubscription.paymentProvider; + switch (_currentSubscription.paymentProvider) { + case stripe: + await _launchStripePortal(); + break; + case playStore: + launchUrlString( + "https://play.google.com/store/account/subscriptions?sku=" + + _currentSubscription.productID + + "&package=io.ente.authenticator", + ); + break; + case appStore: + launchUrlString("https://apps.apple.com/account/billing"); + break; + default: + final String capitalizedWord = paymentProvider.isNotEmpty + ? '${paymentProvider[0].toUpperCase()}${paymentProvider.substring(1).toLowerCase()}' + : ''; + showErrorDialog( + context, + "Sorry", + "Please contact us at support@ente.io to manage your " + "$capitalizedWord subscription.", + ); + } + }, + child: Container( + padding: const EdgeInsets.fromLTRB(40, 80, 40, 20), + child: Column( + children: [ + RichText( + text: TextSpan( + text: "Payment details", + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontFamily: 'Inter-Medium', + fontSize: 14, + decoration: TextDecoration.underline, + ), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ]); + } + + if (!widget.isOnboarding) { + widgets.addAll([ + Align( + alignment: Alignment.topCenter, + child: GestureDetector( + onTap: () async { + await _launchFamilyPortal(); + }, + child: Container( + padding: const EdgeInsets.fromLTRB(40, 0, 40, 80), + child: Column( + children: [ + RichText( + text: TextSpan( + text: "Manage family", + style: Theme.of(context).textTheme.overline, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ]); + } + + return SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: widgets, + ), + ); + } + + Future _launchStripePortal() async { + await _dialog.show(); + try { + final String url = await _billingService.getStripeCustomerPortalUrl(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return WebPage("Payment details", url); + }, + ), + ).then((value) => onWebPaymentGoBack); + } catch (e) { + await _dialog.hide(); + showGenericErrorDialog(context); + } + await _dialog.hide(); + } + + Future _launchFamilyPortal() async { + if (_userDetails.subscription.productID == freeProductID) { + await showErrorDialog( + context, + "Now you can share your storage plan with your family members!", + "Customers on paid plans can add up to 5 family members without paying extra. Each member gets their own private space.", + ); + return; + } + await _dialog.show(); + try { + final String jwtToken = await _userService.getFamiliesToken(); + final bool familyExist = _userDetails.isPartOfFamily(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return WebPage( + "Family", + '$kFamilyPlanManagementUrl?token=$jwtToken&isFamilyCreated=$familyExist', + ); + }, + ), + ).then((value) => onWebPaymentGoBack); + } catch (e) { + await _dialog.hide(); + showGenericErrorDialog(context); + } + await _dialog.hide(); + } + + Widget _stripeRenewOrCancelButton() { + final bool isRenewCancelled = + _currentSubscription.attributes?.isCancelled ?? false; + final String title = + isRenewCancelled ? "Renew subscription" : "Cancel subscription"; + return TextButton( + child: Text( + title, + style: TextStyle( + color: (isRenewCancelled + ? Colors.greenAccent + : Theme.of(context).colorScheme.onSurface) + .withOpacity(isRenewCancelled ? 1.0 : 0.2), + ), + ), + onPressed: () async { + bool confirmAction = false; + if (isRenewCancelled) { + final choice = await showChoiceDialog( + context, + title, + "Are you sure you want to renew?", + firstAction: "No", + secondAction: "Yes", + ); + confirmAction = choice == DialogUserChoice.secondChoice; + } else { + final choice = await showChoiceDialog( + context, + title, + 'Are you sure you want to cancel?', + firstAction: 'Yes, cancel', + secondAction: 'No', + actionType: ActionType.critical, + ); + confirmAction = choice == DialogUserChoice.firstChoice; + } + if (confirmAction) { + toggleStripeSubscription(isRenewCancelled); + } + }, + ); + } + + Future toggleStripeSubscription(bool isRenewCancelled) async { + await _dialog.show(); + try { + isRenewCancelled + ? await _billingService.activateStripeSubscription() + : await _billingService.cancelStripeSubscription(); + await _fetchSub(); + } catch (e) { + showToast( + context, + isRenewCancelled ? 'failed to renew' : 'failed to cancel', + ); + } + await _dialog.hide(); + } + + List _getStripePlanWidgets() { + final List planWidgets = []; + bool foundActivePlan = false; + for (final plan in _plans) { + final productID = plan.stripeID; + if (productID == null || productID.isEmpty) { + continue; + } + final isActive = + _hasActiveSubscription && _currentSubscription.productID == productID; + if (isActive) { + foundActivePlan = true; + } + planWidgets.add( + Material( + child: InkWell( + onTap: () async { + if (isActive) { + return; + } + // prompt user to cancel their active subscription form other + // payment providers + if (!_isStripeSubscriber && + _hasActiveSubscription && + _currentSubscription.productID != freeProductID) { + showErrorDialog( + context, + "Sorry", + "Please cancel your existing subscription from " + "${_currentSubscription.paymentProvider} first", + ); + return; + } + if (_userDetails.getFamilyOrPersonalUsage() > plan.storage) { + showErrorDialog( + context, + "Sorry", + "You cannot downgrade to this plan", + ); + return; + } + String stripPurChaseAction = 'buy'; + if (_isStripeSubscriber && _hasActiveSubscription) { + // confirm if user wants to change plan or not + final result = await showChoiceDialog( + context, + "Confirm plan change", + "Are you sure you want to change your plan?", + firstAction: "No", + secondAction: 'Yes', + ); + if (result != DialogUserChoice.secondChoice) { + return; + } + stripPurChaseAction = 'update'; + } + Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) { + return PaymentWebPage( + planId: plan.stripeID, + actionType: stripPurChaseAction, + ); + }, + ), + ).then((value) => onWebPaymentGoBack(value)); + }, + child: SubscriptionPlanWidget( + storage: plan.storage, + price: plan.price, + period: plan.period, + isActive: isActive, + ), + ), + ), + ); + } + if (!foundActivePlan && _hasActiveSubscription) { + _addCurrentPlanWidget(planWidgets); + } + return planWidgets; + } + + Widget _showSubscriptionToggle() { + Widget _planText(String title, bool reduceOpacity) { + return Padding( + padding: const EdgeInsets.only(left: 4, right: 4), + child: Text( + title, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface + .withOpacity(reduceOpacity ? 0.5 : 1.0), + ), + ), + ); + } + + return Container( + padding: const EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 4), + margin: const EdgeInsets.only(bottom: 12), + // color: Color.fromRGBO(10, 40, 40, 0.3), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _planText("Monthly", _showYearlyPlan), + Switch( + value: _showYearlyPlan, + activeColor: Colors.white, + inactiveThumbColor: Colors.white, + onChanged: (value) async { + _showYearlyPlan = value; + await _filterStripeForUI(); + }, + ), + _planText("Yearly", !_showYearlyPlan) + ], + ), + ); + } + + void _addCurrentPlanWidget(List planWidgets) { + // don't add current plan if it's monthly plan but UI is showing yearly plans + // and vice versa. + if (_showYearlyPlan != _currentSubscription.isYearlyPlan() && + _currentSubscription.productID != freeProductID) { + return; + } + int activePlanIndex = 0; + for (; activePlanIndex < _plans.length; activePlanIndex++) { + if (_plans[activePlanIndex].storage > _currentSubscription.storage) { + break; + } + } + planWidgets.insert( + activePlanIndex, + Material( + child: InkWell( + onTap: () {}, + child: SubscriptionPlanWidget( + storage: _currentSubscription.storage, + price: _currentSubscription.price, + period: _currentSubscription.period, + isActive: true, + ), + ), + ), + ); + } +} diff --git a/lib/ui/payment/subscription.dart b/lib/ui/payment/subscription.dart new file mode 100644 index 000000000..ddc9b8f63 --- /dev/null +++ b/lib/ui/payment/subscription.dart @@ -0,0 +1,25 @@ +// @dart=2.9 +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/services/update_service.dart'; +import 'package:ente_auth/ui/payment/stripe_subscription_page.dart'; +import 'package:ente_auth/ui/payment/subscription_page.dart'; +import 'package:flutter/cupertino.dart'; + +StatefulWidget getSubscriptionPage({bool isOnBoarding = false}) { + if (UpdateService.instance.isIndependentFlavor()) { + return StripeSubscriptionPage(isOnboarding: isOnBoarding); + } + if (_isUserCreatedPostStripeSupport()) { + return StripeSubscriptionPage(isOnboarding: isOnBoarding); + } else { + return SubscriptionPage(isOnboarding: isOnBoarding); + } +} + +// return true if the user was created after we added support for stripe payment +// on frame. We do this check to avoid showing Stripe payment option for earlier +// users who might have paid via playStore. This method should be removed once +// we have better handling for active play/app store subscription & stripe plans. +bool _isUserCreatedPostStripeSupport() { + return Configuration.instance.getUserID() > 1580559962386460; +} diff --git a/lib/ui/payment/subscription_common_widgets.dart b/lib/ui/payment/subscription_common_widgets.dart new file mode 100644 index 000000000..4e91ea046 --- /dev/null +++ b/lib/ui/payment/subscription_common_widgets.dart @@ -0,0 +1,142 @@ +// @dart=2.9 + +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/models/subscription.dart'; +import 'package:ente_auth/ui/payment/billing_questions_widget.dart'; +import 'package:ente_auth/utils/data_util.dart'; +import 'package:ente_auth/utils/date_time_util.dart'; +import 'package:flutter/material.dart'; + +class SubscriptionHeaderWidget extends StatefulWidget { + final bool isOnboarding; + final int currentUsage; + + const SubscriptionHeaderWidget({ + Key key, + this.isOnboarding, + this.currentUsage, + }) : super(key: key); + + @override + State createState() { + return _SubscriptionHeaderWidgetState(); + } +} + +class _SubscriptionHeaderWidgetState extends State { + @override + Widget build(BuildContext context) { + if (widget.isOnboarding) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + "Select your plan", + style: Theme.of(context).textTheme.headline4, + ), + ], + ), + const SizedBox( + height: 10, + ), + Text( + "Ente preserves your memories, so they're always available to you, even if you lose your device ", + style: Theme.of(context).textTheme.caption, + ), + ], + ), + ); + } else { + return SizedBox( + height: 72, + width: double.infinity, + child: Padding( + padding: const EdgeInsets.all(24.0), + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: "Current usage is ", + style: Theme.of(context).textTheme.subtitle1, + ), + TextSpan( + text: formatBytes(widget.currentUsage), + style: Theme.of(context) + .textTheme + .subtitle1 + .copyWith(fontWeight: FontWeight.bold), + ) + ], + ), + ), + ), + ); + } + } +} + +class ValidityWidget extends StatelessWidget { + final Subscription currentSubscription; + + const ValidityWidget({Key key, this.currentSubscription}) : super(key: key); + + @override + Widget build(BuildContext context) { + if (currentSubscription == null) { + return const SizedBox.shrink(); + } + final endDate = getDateAndMonthAndYear( + DateTime.fromMicrosecondsSinceEpoch(currentSubscription.expiryTime), + ); + var message = "Renews on $endDate"; + if (currentSubscription.productID == freeProductID) { + message = "Free plan valid till $endDate"; + } else if (currentSubscription.attributes?.isCancelled ?? false) { + message = "Your subscription will be cancelled on $endDate"; + } + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + message, + style: Theme.of(context).textTheme.caption, + ), + ); + } +} + +class SubFaqWidget extends StatelessWidget { + const SubFaqWidget({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.bottomCenter, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + showModalBottomSheet( + backgroundColor: Theme.of(context).colorScheme.bgColorForQuestions, + barrierColor: Colors.black87, + context: context, + builder: (context) { + return const BillingQuestionsWidget(); + }, + ); + }, + child: Container( + padding: const EdgeInsets.all(40), + child: RichText( + text: TextSpan( + text: "Questions?", + style: Theme.of(context).textTheme.overline, + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/payment/subscription_page.dart b/lib/ui/payment/subscription_page.dart new file mode 100644 index 000000000..a4e1e8bf1 --- /dev/null +++ b/lib/ui/payment/subscription_page.dart @@ -0,0 +1,516 @@ +// @dart=2.9 + +import 'dart:async'; +import 'dart:io'; + +import 'package:ente_auth/core/errors.dart'; +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/events/subscription_purchased_event.dart'; +import 'package:ente_auth/models/billing_plan.dart'; +import 'package:ente_auth/models/subscription.dart'; +import 'package:ente_auth/models/user_details.dart'; +import 'package:ente_auth/services/billing_service.dart'; +import 'package:ente_auth/services/user_service.dart'; +import 'package:ente_auth/ui/common/loading_widget.dart'; +import 'package:ente_auth/ui/common/progress_dialog.dart'; +import 'package:ente_auth/ui/common/web_page.dart'; +import 'package:ente_auth/ui/payment/child_subscription_widget.dart'; +import 'package:ente_auth/ui/payment/skip_subscription_widget.dart'; +import 'package:ente_auth/ui/payment/subscription_common_widgets.dart'; +import 'package:ente_auth/ui/payment/subscription_plan_widget.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/toast_util.dart'; +import 'package:flutter/material.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:logging/logging.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class SubscriptionPage extends StatefulWidget { + final bool isOnboarding; + + const SubscriptionPage({ + this.isOnboarding = false, + Key key, + }) : super(key: key); + + @override + State createState() => _SubscriptionPageState(); +} + +class _SubscriptionPageState extends State { + final _logger = Logger("SubscriptionPage"); + final _billingService = BillingService.instance; + final _userService = UserService.instance; + Subscription _currentSubscription; + StreamSubscription _purchaseUpdateSubscription; + ProgressDialog _dialog; + UserDetails _userDetails; + bool _hasActiveSubscription; + FreePlan _freePlan; + List _plans; + bool _hasLoadedData = false; + bool _isLoading = false; + bool _isActiveStripeSubscriber; + + @override + void initState() { + _billingService.setIsOnSubscriptionPage(true); + _setupPurchaseUpdateStreamListener(); + super.initState(); + } + + void _setupPurchaseUpdateStreamListener() { + _purchaseUpdateSubscription = InAppPurchaseConnection + .instance.purchaseUpdatedStream + .listen((purchases) async { + if (!_dialog.isShowing()) { + await _dialog.show(); + } + for (final purchase in purchases) { + _logger.info("Purchase status " + purchase.status.toString()); + if (purchase.status == PurchaseStatus.purchased) { + try { + final newSubscription = await _billingService.verifySubscription( + purchase.productID, + purchase.verificationData.serverVerificationData, + ); + await InAppPurchaseConnection.instance.completePurchase(purchase); + String text = "Thank you for subscribing!"; + if (!widget.isOnboarding) { + final isUpgrade = _hasActiveSubscription && + newSubscription.storage > _currentSubscription.storage; + final isDowngrade = _hasActiveSubscription && + newSubscription.storage < _currentSubscription.storage; + if (isUpgrade) { + text = "Your plan was successfully upgraded"; + } else if (isDowngrade) { + text = "Your plan was successfully downgraded"; + } + } + showToast(context, text); + _currentSubscription = newSubscription; + _hasActiveSubscription = _currentSubscription.isValid(); + setState(() {}); + await _dialog.hide(); + Bus.instance.fire(SubscriptionPurchasedEvent()); + if (widget.isOnboarding) { + Navigator.of(context).popUntil((route) => route.isFirst); + } + } on SubscriptionAlreadyClaimedError catch (e) { + _logger.warning("subscription is already claimed ", e); + await _dialog.hide(); + final String title = "${Platform.isAndroid ? "Play" : "App"}" + "Store subscription"; + final String id = + Platform.isAndroid ? "Google Play ID" : "Apple ID"; + final String message = '''Your $id is already linked to another + ente account.\nIf you would like to use your $id with this + account, please contact our support'''; + showErrorDialog(context, title, message); + return; + } catch (e) { + _logger.warning("Could not complete payment ", e); + await _dialog.hide(); + showErrorDialog( + context, + "Payment failed", + "Please talk to " + + (Platform.isAndroid ? "PlayStore" : "AppStore") + + " support if you were charged", + ); + return; + } + } else if (Platform.isIOS && purchase.pendingCompletePurchase) { + await InAppPurchaseConnection.instance.completePurchase(purchase); + await _dialog.hide(); + } else if (purchase.status == PurchaseStatus.error) { + await _dialog.hide(); + } + } + }); + } + + @override + void dispose() { + _purchaseUpdateSubscription.cancel(); + _billingService.setIsOnSubscriptionPage(false); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_isLoading) { + _isLoading = true; + _fetchSubData(); + } + _dialog = createProgressDialog(context, "Please wait..."); + final appBar = AppBar( + title: widget.isOnboarding ? null : const Text("Subscription"), + ); + return Scaffold( + appBar: appBar, + body: _getBody(), + ); + } + + Future _fetchSubData() async { + _userService.getUserDetailsV2(memoryCount: false).then((userDetails) async { + _userDetails = userDetails; + _currentSubscription = userDetails.subscription; + _hasActiveSubscription = _currentSubscription.isValid(); + final billingPlans = await _billingService.getBillingPlans(); + _isActiveStripeSubscriber = + _currentSubscription.paymentProvider == stripe && + _currentSubscription.isValid(); + _plans = billingPlans.plans.where((plan) { + final productID = _isActiveStripeSubscriber + ? plan.stripeID + : Platform.isAndroid + ? plan.androidID + : plan.iosID; + return productID != null && productID.isNotEmpty; + }).toList(); + _freePlan = billingPlans.freePlan; + _hasLoadedData = true; + setState(() {}); + }); + } + + Widget _getBody() { + if (_hasLoadedData) { + if (_userDetails.isPartOfFamily() && !_userDetails.isFamilyAdmin()) { + return ChildSubscriptionWidget(userDetails: _userDetails); + } else { + return _buildPlans(); + } + } + return const EnteLoadingWidget(); + } + + Widget _buildPlans() { + final widgets = []; + widgets.add( + SubscriptionHeaderWidget( + isOnboarding: widget.isOnboarding, + currentUsage: _userDetails.getFamilyOrPersonalUsage(), + ), + ); + + widgets.addAll([ + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: _isActiveStripeSubscriber + ? _getStripePlanWidgets() + : _getMobilePlanWidgets(), + ), + const Padding(padding: EdgeInsets.all(8)), + ]); + + if (_hasActiveSubscription) { + widgets.add(ValidityWidget(currentSubscription: _currentSubscription)); + } + + if (_currentSubscription.productID == freeProductID) { + if (widget.isOnboarding) { + widgets.add(SkipSubscriptionWidget(freePlan: _freePlan)); + } + widgets.add(const SubFaqWidget()); + } + + if (_hasActiveSubscription && + _currentSubscription.productID != freeProductID) { + widgets.addAll([ + Align( + alignment: Alignment.center, + child: GestureDetector( + onTap: () { + final String paymentProvider = + _currentSubscription.paymentProvider; + if (paymentProvider == appStore && !Platform.isAndroid) { + launchUrlString("https://apps.apple.com/account/billing"); + } else if (paymentProvider == playStore && Platform.isAndroid) { + launchUrlString( + "https://play.google.com/store/account/subscriptions?sku=" + + _currentSubscription.productID + + "&package=io.ente.authenticator", + ); + } else if (paymentProvider == stripe) { + showErrorDialog( + context, + "Sorry", + "Visit web.ente.io to manage your subscription", + ); + } else { + final String capitalizedWord = paymentProvider.isNotEmpty + ? '${paymentProvider[0].toUpperCase()}${paymentProvider.substring(1).toLowerCase()}' + : ''; + showErrorDialog( + context, + "Sorry", + "Please contact us at support@ente.io to manage your " + "$capitalizedWord subscription.", + ); + } + }, + child: Container( + padding: const EdgeInsets.fromLTRB(40, 80, 40, 20), + child: Column( + children: [ + RichText( + text: TextSpan( + text: _isActiveStripeSubscriber + ? "Visit web.ente.io to manage your subscription" + : "Payment details", + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontFamily: 'Inter-Medium', + fontSize: 14, + decoration: _isActiveStripeSubscriber + ? TextDecoration.none + : TextDecoration.underline, + ), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ]); + } + if (!widget.isOnboarding) { + widgets.addAll([ + Align( + alignment: Alignment.topCenter, + child: GestureDetector( + onTap: () async { + _launchFamilyPortal(); + }, + child: Container( + padding: const EdgeInsets.fromLTRB(40, 0, 40, 80), + child: Column( + children: [ + RichText( + text: TextSpan( + text: "Manage family", + style: Theme.of(context).textTheme.overline, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ]); + } + return SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: widgets, + ), + ); + } + + List _getStripePlanWidgets() { + final List planWidgets = []; + bool foundActivePlan = false; + for (final plan in _plans) { + final productID = plan.stripeID; + if (productID == null || productID.isEmpty) { + continue; + } + final isActive = + _hasActiveSubscription && _currentSubscription.productID == productID; + if (isActive) { + foundActivePlan = true; + } + planWidgets.add( + Material( + color: Colors.transparent, + child: InkWell( + onTap: () async { + if (isActive) { + return; + } + showErrorDialog( + context, + "Sorry", + "Please visit web.ente.io to manage your subscription", + ); + }, + child: SubscriptionPlanWidget( + storage: plan.storage, + price: plan.price, + period: plan.period, + isActive: isActive, + ), + ), + ), + ); + } + if (!foundActivePlan && _hasActiveSubscription) { + _addCurrentPlanWidget(planWidgets); + } + return planWidgets; + } + + List _getMobilePlanWidgets() { + bool foundActivePlan = false; + final List planWidgets = []; + if (_hasActiveSubscription && + _currentSubscription.productID == freeProductID) { + foundActivePlan = true; + planWidgets.add( + SubscriptionPlanWidget( + storage: _freePlan.storage, + price: "free", + period: "", + isActive: true, + ), + ); + } + for (final plan in _plans) { + final productID = Platform.isAndroid ? plan.androidID : plan.iosID; + final isActive = + _hasActiveSubscription && _currentSubscription.productID == productID; + if (isActive) { + foundActivePlan = true; + } + planWidgets.add( + Material( + child: InkWell( + onTap: () async { + if (isActive) { + return; + } + if (_userDetails.getFamilyOrPersonalUsage() > plan.storage) { + showErrorDialog( + context, + "Sorry", + "you cannot downgrade to this plan", + ); + return; + } + await _dialog.show(); + final ProductDetailsResponse response = + await InAppPurchaseConnection.instance + .queryProductDetails({productID}); + if (response.notFoundIDs.isNotEmpty) { + _logger.severe( + "Could not find products: " + response.notFoundIDs.toString(), + ); + await _dialog.hide(); + showGenericErrorDialog(context); + return; + } + final isCrossGradingOnAndroid = Platform.isAndroid && + _hasActiveSubscription && + _currentSubscription.productID != freeProductID && + _currentSubscription.productID != plan.androidID; + if (isCrossGradingOnAndroid) { + final existingProductDetailsResponse = + await InAppPurchaseConnection.instance + .queryProductDetails({_currentSubscription.productID}); + if (existingProductDetailsResponse.notFoundIDs.isNotEmpty) { + _logger.severe( + "Could not find existing products: " + + response.notFoundIDs.toString(), + ); + await _dialog.hide(); + showGenericErrorDialog(context); + return; + } + final subscriptionChangeParam = ChangeSubscriptionParam( + oldPurchaseDetails: PurchaseDetails( + purchaseID: null, + productID: _currentSubscription.productID, + verificationData: null, + transactionDate: null, + ), + ); + await InAppPurchaseConnection.instance.buyNonConsumable( + purchaseParam: PurchaseParam( + productDetails: response.productDetails[0], + changeSubscriptionParam: subscriptionChangeParam, + ), + ); + } else { + await InAppPurchaseConnection.instance.buyNonConsumable( + purchaseParam: PurchaseParam( + productDetails: response.productDetails[0], + ), + ); + } + }, + child: SubscriptionPlanWidget( + storage: plan.storage, + price: plan.price, + period: plan.period, + isActive: isActive, + ), + ), + ), + ); + } + if (!foundActivePlan && _hasActiveSubscription) { + _addCurrentPlanWidget(planWidgets); + } + return planWidgets; + } + + void _addCurrentPlanWidget(List planWidgets) { + int activePlanIndex = 0; + for (; activePlanIndex < _plans.length; activePlanIndex++) { + if (_plans[activePlanIndex].storage > _currentSubscription.storage) { + break; + } + } + planWidgets.insert( + activePlanIndex, + Material( + child: InkWell( + onTap: () {}, + child: SubscriptionPlanWidget( + storage: _currentSubscription.storage, + price: _currentSubscription.price, + period: _currentSubscription.period, + isActive: true, + ), + ), + ), + ); + } + + // todo: refactor manage family in common widget + Future _launchFamilyPortal() async { + if (_userDetails.subscription.productID == freeProductID) { + await showErrorDialog( + context, + "Share your storage plan with your family members!", + "Customers on paid plans can add up to 5 family members without paying extra. Each member gets their own private space.", + ); + return; + } + await _dialog.show(); + try { + final String jwtToken = await _userService.getFamiliesToken(); + final bool familyExist = _userDetails.isPartOfFamily(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return WebPage( + "Family", + '$kFamilyPlanManagementUrl?token=$jwtToken&isFamilyCreated=$familyExist', + ); + }, + ), + ); + } catch (e) { + await _dialog.hide(); + showGenericErrorDialog(context); + } + await _dialog.hide(); + } +} diff --git a/lib/ui/payment/subscription_plan_widget.dart b/lib/ui/payment/subscription_plan_widget.dart new file mode 100644 index 000000000..fbd832ebe --- /dev/null +++ b/lib/ui/payment/subscription_plan_widget.dart @@ -0,0 +1,77 @@ +// @dart=2.9 + +import 'package:ente_auth/utils/data_util.dart'; +import 'package:flutter/material.dart'; + +class SubscriptionPlanWidget extends StatelessWidget { + const SubscriptionPlanWidget({ + Key key, + @required this.storage, + @required this.price, + @required this.period, + this.isActive = false, + }) : super(key: key); + + final int storage; + final String price; + final String period; + final bool isActive; + + String _displayPrice() { + final result = price + (period.isNotEmpty ? " / " + period : ""); + return result.isNotEmpty ? result : "Trial plan"; + } + + @override + Widget build(BuildContext context) { + final Color textColor = isActive ? Colors.white : Colors.black; + return Container( + width: double.infinity, + color: Theme.of(context).colorScheme.onPrimary, + padding: EdgeInsets.symmetric(horizontal: isActive ? 8 : 16, vertical: 4), + child: Container( + decoration: BoxDecoration( + color: isActive + ? const Color(0xFF22763F) + : const Color.fromRGBO(240, 240, 240, 1.0), + gradient: isActive + ? const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Color(0xFF2CD267), + Color(0xFF1DB954), + ], + ) + : null, + ), + // color: Colors.yellow, + padding: + EdgeInsets.symmetric(horizontal: isActive ? 22 : 20, vertical: 18), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + convertBytesToReadableFormat(storage), + style: Theme.of(context) + .textTheme + .headline6 + .copyWith(color: textColor), + ), + Text( + _displayPrice(), + style: Theme.of(context).textTheme.headline6.copyWith( + color: textColor, + fontWeight: FontWeight.normal, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/scanner_page.dart b/lib/ui/scanner_page.dart new file mode 100644 index 000000000..d0b35ef43 --- /dev/null +++ b/lib/ui/scanner_page.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +import 'package:ente_auth/models/code.dart'; +import 'package:flutter/material.dart'; +import 'package:qr_code_scanner/qr_code_scanner.dart'; + +class ScannerPage extends StatefulWidget { + const ScannerPage({Key? key}) : super(key: key); + + @override + State createState() => ScannerPageState(); +} + +class ScannerPageState extends State { + final GlobalKey qrKey = GlobalKey(debugLabel: 'QR'); + QRViewController? controller; + String? totp; + + // In order to get hot reload to work we need to pause the camera if the platform + // is android, or resume the camera if the platform is iOS. + @override + void reassemble() { + super.reassemble(); + if (Platform.isAndroid) { + controller!.pauseCamera(); + } else if (Platform.isIOS) { + controller!.resumeCamera(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Scan"), + ), + body: Column( + children: [ + Expanded( + flex: 5, + child: QRView( + key: qrKey, + onQRViewCreated: _onQRViewCreated, + formatsAllowed: const [BarcodeFormat.qrcode], + ), + ), + Expanded( + flex: 1, + child: Center( + child: (totp != null) ? Text(totp!) : const Text('Scan a code'), + ), + ) + ], + ), + ); + } + + void _onQRViewCreated(QRViewController controller) { + this.controller = controller; + controller.scannedDataStream.listen((scanData) { + try { + final code = Code.fromRawData(scanData.code!); + controller.dispose(); + Navigator.of(context).pop(code); + } catch (e) { + // Log + } + }); + } + + @override + void dispose() { + controller?.dispose(); + super.dispose(); + } +} diff --git a/lib/ui/settings/about_section_widget.dart b/lib/ui/settings/about_section_widget.dart new file mode 100644 index 000000000..42e09778e --- /dev/null +++ b/lib/ui/settings/about_section_widget.dart @@ -0,0 +1,131 @@ +// @dart=2.9 + +import 'package:ente_auth/services/update_service.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/common/web_page.dart'; +import 'package:ente_auth/ui/components/captioned_text_widget.dart'; +import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart'; +import 'package:ente_auth/ui/components/menu_item_widget.dart'; +import 'package:ente_auth/ui/settings/app_update_dialog.dart'; +import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/toast_util.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class AboutSectionWidget extends StatelessWidget { + const AboutSectionWidget({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ExpandableMenuItemWidget( + title: "About", + selectionOptionsWidget: _getSectionOptions(context), + leadingIcon: Icons.info_outline, + ); + } + + Widget _getSectionOptions(BuildContext context) { + return Column( + children: [ + sectionOptionSpacing, + const AboutMenuItemWidget( + title: "FAQ", + url: "https://ente.io/faq", + ), + sectionOptionSpacing, + const AboutMenuItemWidget( + title: "Terms", + url: "https://ente.io/terms", + ), + sectionOptionSpacing, + const AboutMenuItemWidget( + title: "Privacy", + url: "https://ente.io/privacy", + ), + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Source code", + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + launchUrl(Uri.parse("https://github.com/ente-io/auth")); + }, + ), + sectionOptionSpacing, + UpdateService.instance.isIndependent() + ? Column( + children: [ + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Check for updates", + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + final dialog = + createProgressDialog(context, "Checking..."); + await dialog.show(); + final shouldUpdate = + await UpdateService.instance.shouldUpdate(); + await dialog.hide(); + if (shouldUpdate) { + showDialog( + context: context, + builder: (BuildContext context) { + return AppUpdateDialog( + UpdateService.instance.getLatestVersionInfo(), + ); + }, + barrierColor: Colors.black.withOpacity(0.85), + ); + } else { + showToast(context, "You are on the latest version"); + } + }, + ), + sectionOptionSpacing, + ], + ) + : const SizedBox.shrink(), + ], + ); + } +} + +class AboutMenuItemWidget extends StatelessWidget { + final String title; + final String url; + final String webPageTitle; + const AboutMenuItemWidget({ + @required this.title, + @required this.url, + this.webPageTitle, + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: title, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return WebPage(webPageTitle ?? title, url); + }, + ), + ); + }, + ); + } +} diff --git a/lib/ui/settings/account_section_widget.dart b/lib/ui/settings/account_section_widget.dart new file mode 100644 index 000000000..a680e0d08 --- /dev/null +++ b/lib/ui/settings/account_section_widget.dart @@ -0,0 +1,220 @@ +// @dart=2.9 + +import 'dart:io'; + +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/services/local_authentication_service.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/account/change_email_dialog.dart'; +import 'package:ente_auth/ui/account/password_entry_page.dart'; +import 'package:ente_auth/ui/account/recovery_key_page.dart'; +import 'package:ente_auth/ui/components/captioned_text_widget.dart'; +import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart'; +import 'package:ente_auth/ui/components/menu_item_widget.dart'; +import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/navigation_util.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_sodium/flutter_sodium.dart'; +import 'package:share_plus/share_plus.dart'; + +class AccountSectionWidget extends StatelessWidget { + final _codeFile = File( + Configuration.instance.getTempDirectory() + "ente-authenticator-codes.txt", + ); + + AccountSectionWidget({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ExpandableMenuItemWidget( + title: "Account", + selectionOptionsWidget: _getSectionOptions(context), + leadingIcon: Icons.account_circle_outlined, + ); + } + + Column _getSectionOptions(BuildContext context) { + List children = []; + if (Configuration.instance.getRecoveryKey() != null) { + children.addAll([ + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Recovery key", + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + final hasAuthenticated = await LocalAuthenticationService.instance + .requestLocalAuthentication( + context, + "Please authenticate to view your recovery key", + ); + if (hasAuthenticated) { + String recoveryKey; + try { + recoveryKey = + Sodium.bin2base64(Configuration.instance.getRecoveryKey()); + } catch (e) { + showGenericErrorDialog(context); + return; + } + routeToPage( + context, + RecoveryKeyPage( + recoveryKey, + "OK", + showAppBar: true, + onDone: () {}, + ), + ); + } + }, + ), + ]); + } + children.addAll([ + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Change email", + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + final hasAuthenticated = await LocalAuthenticationService.instance + .requestLocalAuthentication( + context, + "Please authenticate to change your email", + ); + if (hasAuthenticated) { + showDialog( + context: context, + builder: (BuildContext context) { + return const ChangeEmailDialog(); + }, + barrierColor: Colors.black.withOpacity(0.85), + barrierDismissible: false, + ); + } + }, + ), + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Change password", + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + final hasAuthenticated = await LocalAuthenticationService.instance + .requestLocalAuthentication( + context, + "Please authenticate to change your password", + ); + if (hasAuthenticated) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const PasswordEntryPage( + mode: PasswordEntryMode.update, + ); + }, + ), + ); + } + }, + ), + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Export secrets", + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + _showWarningDialog(context); + }, + ), + sectionOptionSpacing, + ]); + return Column( + children: children, + ); + } + + Future _showWarningDialog(BuildContext context) async { + final AlertDialog alert = AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + title: Text( + "Warning", + style: Theme.of(context).textTheme.headline6, + ), + content: const Text( + "The exported file contains sensitive information. Please store this safely.", + ), + actions: [ + TextButton( + child: const Text( + "I understand", + style: TextStyle( + color: Colors.red, + ), + ), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop('dialog'); + _exportCodes(context); + }, + ), + TextButton( + child: const Text( + "Cancel", + ), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop('dialog'); + }, + ), + ], + ); + + return showDialog( + context: context, + builder: (BuildContext context) { + return alert; + }, + barrierColor: Colors.black12, + ); + } + + Future _exportCodes(BuildContext context) async { + final hasAuthenticated = + await LocalAuthenticationService.instance.requestLocalAuthentication( + context, + "Please authenticate to export your codes", + ); + if (!hasAuthenticated) { + return; + } + if (_codeFile.existsSync()) { + await _codeFile.delete(); + } + final codes = await CodeStore.instance.getAllCodes(); + String data = ""; + for (final code in codes) { + data += code.rawData + "\n"; + } + _codeFile.writeAsStringSync(data); + await Share.shareFiles([_codeFile.path]); + Future.delayed(const Duration(seconds: 15), () async { + if (_codeFile.existsSync()) { + _codeFile.deleteSync(); + } + }); + } +} diff --git a/lib/ui/settings/app_update_dialog.dart b/lib/ui/settings/app_update_dialog.dart new file mode 100644 index 000000000..9c3d69622 --- /dev/null +++ b/lib/ui/settings/app_update_dialog.dart @@ -0,0 +1,206 @@ +// @dart=2.9 + +// import 'package:open_file/open_file.dart'; +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/network.dart'; +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/services/update_service.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; + +class AppUpdateDialog extends StatefulWidget { + final LatestVersionInfo latestVersionInfo; + + const AppUpdateDialog(this.latestVersionInfo, {Key key}) : super(key: key); + + @override + State createState() => _AppUpdateDialogState(); +} + +class _AppUpdateDialogState extends State { + @override + Widget build(BuildContext context) { + final List changelog = []; + for (final log in widget.latestVersionInfo.changelog) { + changelog.add( + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 0, 4), + child: Text("- " + log, style: Theme.of(context).textTheme.caption), + ), + ); + } + final content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.latestVersionInfo.name, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const Padding(padding: EdgeInsets.all(8)), + const Text( + "Changelog", + style: TextStyle( + fontSize: 18, + ), + ), + const Padding(padding: EdgeInsets.all(4)), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: changelog, + ), + const Padding(padding: EdgeInsets.all(8)), + SizedBox( + width: double.infinity, + height: 64, + child: OutlinedButton( + style: Theme.of(context).outlinedButtonTheme.style.copyWith( + textStyle: MaterialStateProperty.resolveWith( + (Set states) { + return Theme.of(context).textTheme.subtitle1; + }, + ), + ), + onPressed: () async { + Navigator.pop(context); + showDialog( + context: context, + builder: (BuildContext context) { + return ApkDownloaderDialog(widget.latestVersionInfo); + }, + barrierDismissible: false, + ); + }, + child: const Text( + "Update", + ), + ), + ), + ], + ); + final shouldForceUpdate = + UpdateService.instance.shouldForceUpdate(widget.latestVersionInfo); + return WillPopScope( + onWillPop: () async => !shouldForceUpdate, + child: AlertDialog( + title: Text( + shouldForceUpdate ? "Critical update available" : "Update available", + ), + content: content, + ), + ); + } +} + +class ApkDownloaderDialog extends StatefulWidget { + final LatestVersionInfo versionInfo; + + const ApkDownloaderDialog(this.versionInfo, {Key key}) : super(key: key); + + @override + State createState() => _ApkDownloaderDialogState(); +} + +class _ApkDownloaderDialogState extends State { + String _saveUrl; + double _downloadProgress; + + @override + void initState() { + super.initState(); + _saveUrl = Configuration.instance.getTempDirectory() + + "ente-" + + widget.versionInfo.name + + ".apk"; + _downloadApk(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async => false, + child: AlertDialog( + title: const Text( + "Downloading...", + style: TextStyle( + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + content: LinearProgressIndicator( + value: _downloadProgress, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.alternativeColor, + ), + ), + ), + ); + } + + Future _downloadApk() async { + try { + await Network.instance.getDio().download( + widget.versionInfo.url, + _saveUrl, + onReceiveProgress: (count, _) { + setState(() { + _downloadProgress = count / widget.versionInfo.size; + }); + }, + ); + Navigator.of(context, rootNavigator: true).pop('dialog'); + // OpenFile.open(_saveUrl); + } catch (e) { + Logger("ApkDownloader").severe(e); + final AlertDialog alert = AlertDialog( + title: const Text("Sorry"), + content: const Text("The download could not be completed"), + actions: [ + TextButton( + child: const Text( + "Ignore", + style: TextStyle( + color: Colors.white, + ), + ), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop('dialog'); + Navigator.of(context, rootNavigator: true).pop('dialog'); + }, + ), + TextButton( + child: Text( + "Retry", + style: TextStyle( + color: Theme.of(context).colorScheme.alternativeColor, + ), + ), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop('dialog'); + Navigator.of(context, rootNavigator: true).pop('dialog'); + showDialog( + context: context, + builder: (BuildContext context) { + return ApkDownloaderDialog(widget.versionInfo); + }, + barrierDismissible: false, + ); + }, + ), + ], + ); + + showDialog( + context: context, + builder: (BuildContext context) { + return alert; + }, + barrierColor: Colors.black87, + ); + return; + } + } +} diff --git a/lib/ui/settings/app_version_widget.dart b/lib/ui/settings/app_version_widget.dart new file mode 100644 index 000000000..ce23ca492 --- /dev/null +++ b/lib/ui/settings/app_version_widget.dart @@ -0,0 +1,68 @@ +// @dart=2.9 + +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +class AppVersionWidget extends StatefulWidget { + const AppVersionWidget({ + Key key, + }) : super(key: key); + + @override + State createState() => _AppVersionWidgetState(); +} + +class _AppVersionWidgetState extends State { + static const kTapThresholdForInspector = 5; + static const kConsecutiveTapTimeWindowInMilliseconds = 2000; + static const kDummyDelayDurationInMilliseconds = 1500; + + int _lastTap; + int _consecutiveTaps = 0; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () async { + final int now = DateTime.now().millisecondsSinceEpoch; + if (now - (_lastTap ?? now) < kConsecutiveTapTimeWindowInMilliseconds) { + _consecutiveTaps++; + if (_consecutiveTaps == kTapThresholdForInspector) { + final dialog = + createProgressDialog(context, "Starting network inspector..."); + await dialog.show(); + await Future.delayed( + const Duration(milliseconds: kDummyDelayDurationInMilliseconds), + ); + await dialog.hide(); + } + } else { + _consecutiveTaps = 1; + } + _lastTap = now; + }, + child: FutureBuilder( + future: _getAppVersion(), + builder: (context, snapshot) { + if (snapshot.hasData) { + return Padding( + padding: const EdgeInsets.all(20), + child: Text( + "Version: " + snapshot.data, + style: Theme.of(context).textTheme.caption, + ), + ); + } + return Container(); + }, + ), + ); + } + + Future _getAppVersion() async { + final pkgInfo = await PackageInfo.fromPlatform(); + return pkgInfo.version; + } +} diff --git a/lib/ui/settings/common_settings.dart b/lib/ui/settings/common_settings.dart new file mode 100644 index 000000000..35648a8f7 --- /dev/null +++ b/lib/ui/settings/common_settings.dart @@ -0,0 +1,23 @@ +// @dart=2.9 + +import 'dart:io'; + +import 'package:expandable/expandable.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +Widget sectionOptionDivider = Padding( + padding: EdgeInsets.all(Platform.isIOS ? 4 : 2), +); + +Widget sectionOptionSpacing = const SizedBox(height: 6); + +ExpandableThemeData getExpandableTheme(BuildContext context) { + return const ExpandableThemeData( + hasIcon: false, + useInkWell: false, + tapBodyToCollapse: true, + tapBodyToExpand: true, + animationDuration: Duration(milliseconds: 400), + ); +} diff --git a/lib/ui/settings/danger_section_widget.dart b/lib/ui/settings/danger_section_widget.dart new file mode 100644 index 000000000..609b8f6e9 --- /dev/null +++ b/lib/ui/settings/danger_section_widget.dart @@ -0,0 +1,95 @@ +// @dart=2.9 + +import 'package:ente_auth/services/user_service.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/account/delete_account_page.dart'; +import 'package:ente_auth/ui/components/captioned_text_widget.dart'; +import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart'; +import 'package:ente_auth/ui/components/menu_item_widget.dart'; +import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:ente_auth/utils/navigation_util.dart'; +import 'package:flutter/material.dart'; + +class DangerSectionWidget extends StatelessWidget { + const DangerSectionWidget({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ExpandableMenuItemWidget( + title: "Exit", + selectionOptionsWidget: _getSectionOptions(context), + leadingIcon: Icons.logout_outlined, + ); + } + + Widget _getSectionOptions(BuildContext context) { + return Column( + children: [ + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Logout", + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () { + _onLogoutTapped(context); + }, + ), + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Delete account", + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () { + routeToPage(context, const DeleteAccountPage()); + }, + ), + sectionOptionSpacing, + ], + ); + } + + Future _onLogoutTapped(BuildContext context) async { + final AlertDialog alert = AlertDialog( + title: const Text( + "Logout", + style: TextStyle( + color: Colors.red, + ), + ), + content: const Text("Are you sure you want to logout?"), + actions: [ + TextButton( + child: const Text( + "Yes, logout", + style: TextStyle( + color: Colors.red, + ), + ), + onPressed: () async { + Navigator.of(context, rootNavigator: true).pop('dialog'); + await UserService.instance.logout(context); + }, + ), + TextButton( + child: const Text("No"), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop('dialog'); + }, + ), + ], + ); + + showDialog( + context: context, + builder: (BuildContext context) { + return alert; + }, + ); + } +} diff --git a/lib/ui/settings/debug_section_widget.dart b/lib/ui/settings/debug_section_widget.dart new file mode 100644 index 000000000..0e29d8c61 --- /dev/null +++ b/lib/ui/settings/debug_section_widget.dart @@ -0,0 +1,91 @@ +// @dart=2.9 + +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:ente_auth/ui/settings/settings_section_title.dart'; +import 'package:ente_auth/ui/settings/settings_text_item.dart'; +import 'package:expandable/expandable.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_sodium/flutter_sodium.dart'; + +class DebugSectionWidget extends StatelessWidget { + const DebugSectionWidget({Key key}) : super(key: key); + @override + Widget build(BuildContext context) { + return ExpandablePanel( + header: const SettingsSectionTitle("Debug"), + collapsed: Container(), + expanded: _getSectionOptions(context), + theme: getExpandableTheme(context), + ); + } + + Widget _getSectionOptions(BuildContext context) { + return Column( + children: [ + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () async { + _showKeyAttributesDialog(context); + }, + child: const SettingsTextItem( + text: "Key attributes", + icon: Icons.navigate_next, + ), + ), + ], + ); + } + + void _showKeyAttributesDialog(BuildContext context) { + final keyAttributes = Configuration.instance.getKeyAttributes(); + final AlertDialog alert = AlertDialog( + title: const Text("key attributes"), + content: SingleChildScrollView( + child: Column( + children: [ + const Text( + "Key", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(Sodium.bin2base64(Configuration.instance.getKey())), + const Padding(padding: EdgeInsets.all(12)), + const Text( + "Encrypted Key", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(keyAttributes.encryptedKey), + const Padding(padding: EdgeInsets.all(12)), + const Text( + "Key Decryption Nonce", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(keyAttributes.keyDecryptionNonce), + const Padding(padding: EdgeInsets.all(12)), + const Text( + "KEK Salt", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(keyAttributes.kekSalt), + const Padding(padding: EdgeInsets.all(12)), + ], + ), + ), + actions: [ + TextButton( + child: const Text("OK"), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop('dialog'); + }, + ), + ], + ); + + showDialog( + context: context, + builder: (BuildContext context) { + return alert; + }, + ); + } +} diff --git a/lib/ui/settings/info_section_widget.dart b/lib/ui/settings/info_section_widget.dart new file mode 100644 index 000000000..52bc7e475 --- /dev/null +++ b/lib/ui/settings/info_section_widget.dart @@ -0,0 +1,125 @@ +// @dart=2.9 + +import 'package:ente_auth/services/update_service.dart'; +import 'package:ente_auth/ui/common/web_page.dart'; +import 'package:ente_auth/ui/settings/app_update_dialog.dart'; +import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:ente_auth/ui/settings/settings_section_title.dart'; +import 'package:ente_auth/ui/settings/settings_text_item.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/toast_util.dart'; +import 'package:expandable/expandable.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class InfoSectionWidget extends StatelessWidget { + const InfoSectionWidget({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ExpandablePanel( + header: const SettingsSectionTitle("About"), + collapsed: Container(), + expanded: _getSectionOptions(context), + theme: getExpandableTheme(context), + ); + } + + Widget _getSectionOptions(BuildContext context) { + return Column( + children: [ + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () async { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const WebPage("FAQ", "https://ente.io/faq"); + }, + ), + ); + }, + child: const SettingsTextItem(text: "FAQ", icon: Icons.navigate_next), + ), + sectionOptionDivider, + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const WebPage("terms", "https://ente.io/terms"); + }, + ), + ); + }, + child: + const SettingsTextItem(text: "Terms", icon: Icons.navigate_next), + ), + sectionOptionDivider, + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const WebPage("privacy", "https://ente.io/privacy"); + }, + ), + ); + }, + child: const SettingsTextItem( + text: "Privacy", + icon: Icons.navigate_next, + ), + ), + sectionOptionDivider, + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () async { + launchUrl(Uri.parse("https://github.com/ente-io/frame")); + }, + child: const SettingsTextItem( + text: "Source code", + icon: Icons.navigate_next, + ), + ), + sectionOptionDivider, + UpdateService.instance.isIndependent() + ? Column( + children: [ + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () async { + final dialog = + createProgressDialog(context, "Checking..."); + await dialog.show(); + final shouldUpdate = + await UpdateService.instance.shouldUpdate(); + await dialog.hide(); + if (shouldUpdate) { + showDialog( + context: context, + builder: (BuildContext context) { + return AppUpdateDialog( + UpdateService.instance.getLatestVersionInfo(), + ); + }, + barrierColor: Colors.black.withOpacity(0.85), + ); + } else { + showToast(context, "You are on the latest version"); + } + }, + child: const SettingsTextItem( + text: "Check for updates", + icon: Icons.navigate_next, + ), + ), + ], + ) + : const SizedBox.shrink(), + ], + ); + } +} diff --git a/lib/ui/settings/security_section_widget.dart b/lib/ui/settings/security_section_widget.dart new file mode 100644 index 000000000..85eefa7a6 --- /dev/null +++ b/lib/ui/settings/security_section_widget.dart @@ -0,0 +1,204 @@ +// @dart=2.9 + +import 'dart:async'; +import 'dart:io'; + +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/events/two_factor_status_change_event.dart'; +import 'package:ente_auth/services/local_authentication_service.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/account/sessions_page.dart'; +import 'package:ente_auth/ui/components/captioned_text_widget.dart'; +import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart'; +import 'package:ente_auth/ui/components/menu_item_widget.dart'; +import 'package:ente_auth/ui/components/toggle_switch_widget.dart'; +import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_windowmanager/flutter_windowmanager.dart'; + +class SecuritySectionWidget extends StatefulWidget { + const SecuritySectionWidget({Key key}) : super(key: key); + + @override + State createState() => _SecuritySectionWidgetState(); +} + +class _SecuritySectionWidgetState extends State { + final _config = Configuration.instance; + + StreamSubscription _twoFactorStatusChangeEvent; + + @override + void initState() { + super.initState(); + _twoFactorStatusChangeEvent = + Bus.instance.on().listen((event) async { + if (mounted) { + setState(() {}); + } + }); + } + + @override + void dispose() { + _twoFactorStatusChangeEvent.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ExpandableMenuItemWidget( + title: "Security", + selectionOptionsWidget: _getSectionOptions(context), + leadingIcon: Icons.local_police_outlined, + ); + } + + Widget _getSectionOptions(BuildContext context) { + final List children = []; + children.addAll([ + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Lockscreen", + ), + trailingSwitch: ToggleSwitchWidget( + value: _config.shouldShowLockScreen(), + onChanged: (value) async { + final hasAuthenticated = await LocalAuthenticationService.instance + .requestLocalAuthForLockScreen( + context, + value, + "Please authenticate to change lockscreen setting", + "To enable lockscreen, please setup device passcode or screen lock in your system settings.", + ); + if (hasAuthenticated) { + setState(() {}); + } + }, + ), + ), + sectionOptionSpacing, + ]); + if (Platform.isAndroid) { + children.addAll( + [ + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Hide from recents", + ), + trailingSwitch: ToggleSwitchWidget( + value: _config.shouldHideFromRecents(), + onChanged: (value) async { + if (value) { + final AlertDialog alert = AlertDialog( + title: const Text("Hide from recents?"), + content: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text( + "Hiding from the task switcher will prevent you from taking screenshots in this app.", + style: TextStyle( + height: 1.5, + ), + ), + Padding(padding: EdgeInsets.all(8)), + Text( + "Are you sure?", + style: TextStyle( + height: 1.5, + ), + ), + ], + ), + ), + actions: [ + TextButton( + child: Text( + "No", + style: TextStyle( + color: + Theme.of(context).colorScheme.defaultTextColor, + ), + ), + onPressed: () { + Navigator.of(context, rootNavigator: true) + .pop('dialog'); + }, + ), + TextButton( + child: Text( + "Yes", + style: TextStyle( + color: + Theme.of(context).colorScheme.defaultTextColor, + ), + ), + onPressed: () async { + Navigator.of(context, rootNavigator: true) + .pop('dialog'); + await _config.setShouldHideFromRecents(true); + await FlutterWindowManager.addFlags( + FlutterWindowManager.FLAG_SECURE, + ); + setState(() {}); + }, + ), + ], + ); + + showDialog( + context: context, + builder: (BuildContext context) { + return alert; + }, + ); + } else { + await _config.setShouldHideFromRecents(false); + await FlutterWindowManager.clearFlags( + FlutterWindowManager.FLAG_SECURE, + ); + setState(() {}); + } + }, + ), + ), + sectionOptionSpacing, + ], + ); + } + children.addAll([ + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Active sessions", + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + final hasAuthenticated = await LocalAuthenticationService.instance + .requestLocalAuthentication( + context, + "Please authenticate to view your active sessions", + ); + if (hasAuthenticated) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const SessionsPage(); + }, + ), + ); + } + }, + ), + sectionOptionSpacing, + ]); + return Column( + children: children, + ); + } +} diff --git a/lib/ui/settings/settings_section_title.dart b/lib/ui/settings/settings_section_title.dart new file mode 100644 index 000000000..3c71ac178 --- /dev/null +++ b/lib/ui/settings/settings_section_title.dart @@ -0,0 +1,36 @@ +// @dart=2.9 + +import 'package:flutter/material.dart'; + +class SettingsSectionTitle extends StatelessWidget { + final String title; + final Color color; + + const SettingsSectionTitle( + this.title, { + Key key, + this.color, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const Padding(padding: EdgeInsets.all(4)), + Align( + alignment: Alignment.centerLeft, + child: Text( + title, + style: color != null + ? Theme.of(context) + .textTheme + .headline6 + .merge(TextStyle(color: color)) + : Theme.of(context).textTheme.headline6, + ), + ), + const Padding(padding: EdgeInsets.all(4)), + ], + ); + } +} diff --git a/lib/ui/settings/settings_text_item.dart b/lib/ui/settings/settings_text_item.dart new file mode 100644 index 000000000..32dc01b08 --- /dev/null +++ b/lib/ui/settings/settings_text_item.dart @@ -0,0 +1,35 @@ +// @dart=2.9 + +import 'dart:io'; + +import 'package:flutter/material.dart'; + +class SettingsTextItem extends StatelessWidget { + final String text; + final IconData icon; + const SettingsTextItem({ + Key key, + @required this.text, + @required this.icon, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding(padding: EdgeInsets.all(Platform.isIOS ? 4 : 6)), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Align( + alignment: Alignment.centerLeft, + child: Text(text, style: Theme.of(context).textTheme.subtitle1), + ), + Icon(icon), + ], + ), + Padding(padding: EdgeInsets.all(Platform.isIOS ? 4 : 6)), + ], + ); + } +} diff --git a/lib/ui/settings/social_section_widget.dart b/lib/ui/settings/social_section_widget.dart new file mode 100644 index 000000000..bfa5873f7 --- /dev/null +++ b/lib/ui/settings/social_section_widget.dart @@ -0,0 +1,73 @@ +// @dart=2.9 + +import 'dart:io'; + +import 'package:ente_auth/services/update_service.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/components/captioned_text_widget.dart'; +import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart'; +import 'package:ente_auth/ui/components/menu_item_widget.dart'; +import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class SocialSectionWidget extends StatelessWidget { + const SocialSectionWidget({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ExpandableMenuItemWidget( + title: "Social", + selectionOptionsWidget: _getSectionOptions(context), + leadingIcon: Icons.interests_outlined, + ); + } + + Widget _getSectionOptions(BuildContext context) { + final List options = [ + sectionOptionSpacing, + const SocialsMenuItemWidget("Twitter", "https://twitter.com/enteio"), + sectionOptionSpacing, + const SocialsMenuItemWidget("Discord", "https://ente.io/discord"), + sectionOptionSpacing, + const SocialsMenuItemWidget("Reddit", "https://reddit.com/r/enteio"), + sectionOptionSpacing, + ]; + if (!UpdateService.instance.isIndependent()) { + options.addAll( + [ + SocialsMenuItemWidget( + "Rate us! โœจ", + Platform.isAndroid + ? "https://play.google.com/store/apps/details?id=io.ente.photos" + : "https://apps.apple.com/in/app/ente-photos/id1542026904", + ), + sectionOptionSpacing, + ], + ); + } + return Column(children: options); + } +} + +class SocialsMenuItemWidget extends StatelessWidget { + final String text; + final String urlSring; + const SocialsMenuItemWidget(this.text, this.urlSring, {Key key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: text, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () { + launchUrlString(urlSring); + }, + ); + } +} diff --git a/lib/ui/settings/support_section_widget.dart b/lib/ui/settings/support_section_widget.dart new file mode 100644 index 000000000..24461dacd --- /dev/null +++ b/lib/ui/settings/support_section_widget.dart @@ -0,0 +1,63 @@ +// @dart=2.9 + +import 'dart:io'; + +import 'package:ente_auth/core/constants.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/components/captioned_text_widget.dart'; +import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart'; +import 'package:ente_auth/ui/components/menu_item_widget.dart'; +import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:ente_auth/utils/email_util.dart'; +import 'package:flutter/material.dart'; + +class SupportSectionWidget extends StatelessWidget { + const SupportSectionWidget({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ExpandableMenuItemWidget( + title: "Support", + selectionOptionsWidget: _getSectionOptions(context), + leadingIcon: Icons.help_outline_outlined, + ); + } + + Widget _getSectionOptions(BuildContext context) { + final String bugsEmail = + Platform.isAndroid ? "android-bugs@ente.io" : "ios-bugs@ente.io"; + return Column( + children: [ + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Email", + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + await sendEmail(context, to: supportEmail); + }, + ), + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: const CaptionedTextWidget( + title: "Report a bug", + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + await sendLogs(context, "Report bug", bugsEmail); + }, + onDoubleTap: () async { + final zipFilePath = await getZippedLogsFile(context); + await shareLogs(context, bugsEmail, zipFilePath); + }, + ), + sectionOptionSpacing, + ], + ); + } +} diff --git a/lib/ui/settings/theme_switch_widget.dart b/lib/ui/settings/theme_switch_widget.dart new file mode 100644 index 000000000..856b5bb8c --- /dev/null +++ b/lib/ui/settings/theme_switch_widget.dart @@ -0,0 +1,85 @@ +// @dart=2.9 + +import 'package:adaptive_theme/adaptive_theme.dart'; +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/components/captioned_text_widget.dart'; +import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart'; +import 'package:ente_auth/ui/components/menu_item_widget.dart'; +import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class ThemeSwitchWidget extends StatefulWidget { + const ThemeSwitchWidget({Key key}) : super(key: key); + + @override + State createState() => _ThemeSwitchWidgetState(); +} + +class _ThemeSwitchWidgetState extends State { + AdaptiveThemeMode currentThemeMode; + + @override + void initState() { + super.initState(); + AdaptiveTheme.getThemeMode().then( + (value) { + currentThemeMode = value ?? AdaptiveThemeMode.system; + debugPrint('theme value $value'); + if (mounted) { + setState(() => {}); + } + }, + ); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ExpandableMenuItemWidget( + title: "Theme", + selectionOptionsWidget: _getSectionOptions(context), + leadingIcon: Theme.of(context).brightness == Brightness.light + ? Icons.light_mode_outlined + : Icons.dark_mode_outlined, + ); + } + + Widget _getSectionOptions(BuildContext context) { + return Column( + children: [ + sectionOptionSpacing, + _menuItem(context, AdaptiveThemeMode.light), + sectionOptionSpacing, + _menuItem(context, AdaptiveThemeMode.dark), + sectionOptionSpacing, + _menuItem(context, AdaptiveThemeMode.system), + sectionOptionSpacing, + ], + ); + } + + Widget _menuItem(BuildContext context, AdaptiveThemeMode themeMode) { + return MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: toBeginningOfSentenceCase(themeMode.name), + textStyle: Theme.of(context).colorScheme.enteTheme.textTheme.body, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + isExpandable: false, + trailingIcon: currentThemeMode == themeMode ? Icons.check : null, + onTap: () async { + AdaptiveTheme.of(context).setThemeMode(themeMode); + currentThemeMode = themeMode; + if (mounted) { + setState(() {}); + } + }, + ); + } +} diff --git a/lib/ui/settings/title_bar_widget.dart b/lib/ui/settings/title_bar_widget.dart new file mode 100644 index 000000000..5e0df24ba --- /dev/null +++ b/lib/ui/settings/title_bar_widget.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +class SettingsTitleBarWidget extends StatelessWidget { + const SettingsTitleBarWidget({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 20, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + visualDensity: const VisualDensity(horizontal: -2, vertical: -2), + onPressed: () { + Navigator.pop(context); + }, + icon: const Icon(Icons.keyboard_double_arrow_left_outlined), + ), + const Text("Settings"), + ], + ), + ), + ); + } +} diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart new file mode 100644 index 000000000..6d28e0239 --- /dev/null +++ b/lib/ui/settings_page.dart @@ -0,0 +1,106 @@ +// @dart=2.9 + +import 'dart:io'; + +import 'package:ente_auth/theme/colors.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/settings/about_section_widget.dart'; +import 'package:ente_auth/ui/settings/account_section_widget.dart'; +import 'package:ente_auth/ui/settings/app_version_widget.dart'; +import 'package:ente_auth/ui/settings/danger_section_widget.dart'; +import 'package:ente_auth/ui/settings/security_section_widget.dart'; +import 'package:ente_auth/ui/settings/social_section_widget.dart'; +import 'package:ente_auth/ui/settings/support_section_widget.dart'; +import 'package:ente_auth/ui/settings/theme_switch_widget.dart'; +import 'package:ente_auth/ui/settings/title_bar_widget.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class SettingsPage extends StatelessWidget { + final ValueNotifier emailNotifier; + const SettingsPage({Key key, @required this.emailNotifier}) : super(key: key); + + @override + Widget build(BuildContext context) { + final enteColorScheme = getEnteColorScheme(context); + return Scaffold( + body: Container( + color: enteColorScheme.backdropBase, + child: _getBody(context, enteColorScheme), + ), + ); + } + + Widget _getBody(BuildContext context, EnteColorScheme colorScheme) { + final enteTextTheme = getEnteTextTheme(context); + final List contents = []; + contents.add( + Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Align( + alignment: Alignment.centerLeft, + child: AnimatedBuilder( + // [AnimatedBuilder] accepts any [Listenable] subtype. + animation: emailNotifier, + builder: (BuildContext context, Widget child) { + return Text( + emailNotifier.value, + style: enteTextTheme.body.copyWith( + color: colorScheme.textMuted, + overflow: TextOverflow.ellipsis, + ), + ); + }, + ), + ), + ), + ); + const sectionSpacing = SizedBox(height: 8); + contents.add(const SizedBox(height: 12)); + contents.addAll([ + AccountSectionWidget(), + sectionSpacing, + const SecuritySectionWidget(), + sectionSpacing, + ]); + + if (Platform.isAndroid || kDebugMode) { + contents.addAll([ + const ThemeSwitchWidget(), + sectionSpacing, + ]); + } + + contents.addAll([ + const SupportSectionWidget(), + sectionSpacing, + const SocialSectionWidget(), + sectionSpacing, + const AboutSectionWidget(), + sectionSpacing, + const DangerSectionWidget(), + const AppVersionWidget(), + const Padding( + padding: EdgeInsets.only(bottom: 60), + ), + ]); + + return SafeArea( + bottom: false, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SettingsTitleBarWidget(), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: Column( + children: contents, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/settings_section_title.dart b/lib/ui/settings_section_title.dart new file mode 100644 index 000000000..613744d49 --- /dev/null +++ b/lib/ui/settings_section_title.dart @@ -0,0 +1 @@ +// TODO Implement this library. diff --git a/lib/ui/tools/app_lock.dart b/lib/ui/tools/app_lock.dart new file mode 100644 index 000000000..761974a7d --- /dev/null +++ b/lib/ui/tools/app_lock.dart @@ -0,0 +1,194 @@ +// @dart=2.9 + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// A widget which handles app lifecycle events for showing and hiding a lock screen. +/// This should wrap around a `MyApp` widget (or equivalent). +/// +/// [lockScreen] is a [Widget] which should be a screen for handling login logic and +/// calling `AppLock.of(context).didUnlock();` upon a successful login. +/// +/// [builder] is a [Function] taking an [Object] as its argument and should return a +/// [Widget]. The [Object] argument is provided by the [lockScreen] calling +/// `AppLock.of(context).didUnlock();` with an argument. [Object] can then be injected +/// in to your `MyApp` widget (or equivalent). +/// +/// [enabled] determines wether or not the [lockScreen] should be shown on app launch +/// and subsequent app pauses. This can be changed later on using `AppLock.of(context).enable();`, +/// `AppLock.of(context).disable();` or the convenience method `AppLock.of(context).setEnabled(enabled);` +/// using a bool argument. +/// +/// [backgroundLockLatency] determines how much time is allowed to pass when +/// the app is in the background state before the [lockScreen] widget should be +/// shown upon returning. It defaults to instantly. +/// + +// ignore_for_file: unnecessary_this, library_private_types_in_public_api +class AppLock extends StatefulWidget { + final Widget Function(Object) builder; + final Widget lockScreen; + final bool enabled; + final Duration backgroundLockLatency; + final ThemeData darkTheme; + final ThemeData lightTheme; + + const AppLock({ + Key key, + @required this.builder, + @required this.lockScreen, + this.enabled = true, + this.backgroundLockLatency = const Duration(seconds: 0), + this.darkTheme, + this.lightTheme, + }) : super(key: key); + + static _AppLockState of(BuildContext context) => + context.findAncestorStateOfType<_AppLockState>(); + + @override + State createState() => _AppLockState(); +} + +class _AppLockState extends State with WidgetsBindingObserver { + static final GlobalKey _navigatorKey = GlobalKey(); + + bool _didUnlockForAppLaunch; + bool _isLocked; + bool _enabled; + + Timer _backgroundLockLatencyTimer; + + @override + void initState() { + WidgetsBinding.instance.addObserver(this); + + this._didUnlockForAppLaunch = !this.widget.enabled; + this._isLocked = false; + this._enabled = this.widget.enabled; + + super.initState(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (!this._enabled) { + return; + } + + if (state == AppLifecycleState.paused && + (!this._isLocked && this._didUnlockForAppLaunch)) { + this._backgroundLockLatencyTimer = + Timer(this.widget.backgroundLockLatency, () => this.showLockScreen()); + } + + if (state == AppLifecycleState.resumed) { + this._backgroundLockLatencyTimer?.cancel(); + } + + super.didChangeAppLifecycleState(state); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + + this._backgroundLockLatencyTimer?.cancel(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: this.widget.enabled ? this._lockScreen : this.widget.builder(null), + navigatorKey: _navigatorKey, + themeMode: ThemeMode.system, + theme: widget.lightTheme, + darkTheme: widget.darkTheme, + onGenerateRoute: (settings) { + switch (settings.name) { + case '/lock-screen': + return PageRouteBuilder( + pageBuilder: (_, __, ___) => this._lockScreen, + ); + case '/unlocked': + return PageRouteBuilder( + pageBuilder: (_, __, ___) => + this.widget.builder(settings.arguments), + ); + } + return PageRouteBuilder(pageBuilder: (_, __, ___) => this._lockScreen); + }, + ); + } + + Widget get _lockScreen { + return WillPopScope( + child: this.widget.lockScreen, + onWillPop: () => Future.value(false), + ); + } + + /// Causes `AppLock` to either pop the [lockScreen] if the app is already running + /// or instantiates widget returned from the [builder] method if the app is cold + /// launched. + /// + /// [args] is an optional argument which will get passed to the [builder] method + /// when built. Use this when you want to inject objects created from the + /// [lockScreen] in to the rest of your app so you can better guarantee that some + /// objects, services or databases are already instantiated before using them. + void didUnlock([Object args]) { + if (this._didUnlockForAppLaunch) { + this._didUnlockOnAppPaused(); + } else { + this._didUnlockOnAppLaunch(args); + } + } + + /// Makes sure that [AppLock] shows the [lockScreen] on subsequent app pauses if + /// [enabled] is true of makes sure it isn't shown on subsequent app pauses if + /// [enabled] is false. + /// + /// This is a convenience method for calling the [enable] or [disable] method based + /// on [enabled]. + void setEnabled(bool enabled) { + if (enabled) { + this.enable(); + } else { + this.disable(); + } + } + + /// Makes sure that [AppLock] shows the [lockScreen] on subsequent app pauses. + void enable() { + setState(() { + this._enabled = true; + }); + } + + /// Makes sure that [AppLock] doesn't show the [lockScreen] on subsequent app pauses. + void disable() { + setState(() { + this._enabled = false; + }); + } + + /// Manually show the [lockScreen]. + Future showLockScreen() { + this._isLocked = true; + return _navigatorKey.currentState.pushNamed('/lock-screen'); + } + + void _didUnlockOnAppLaunch(Object args) { + this._didUnlockForAppLaunch = true; + _navigatorKey.currentState + .pushReplacementNamed('/unlocked', arguments: args); + } + + void _didUnlockOnAppPaused() { + this._isLocked = false; + _navigatorKey.currentState.pop(); + } +} diff --git a/lib/ui/tools/debug/log_file_viewer.dart b/lib/ui/tools/debug/log_file_viewer.dart new file mode 100644 index 000000000..28cde4543 --- /dev/null +++ b/lib/ui/tools/debug/log_file_viewer.dart @@ -0,0 +1,59 @@ +// @dart=2.9 + +import 'dart:io'; +import 'dart:ui'; + +import 'package:ente_auth/ui/common/loading_widget.dart'; +import 'package:flutter/material.dart'; + +class LogFileViewer extends StatefulWidget { + final File file; + const LogFileViewer(this.file, {Key key}) : super(key: key); + + @override + State createState() => _LogFileViewerState(); +} + +class _LogFileViewerState extends State { + String _logs; + @override + void initState() { + widget.file.readAsString().then((logs) { + setState(() { + _logs = logs; + }); + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + elevation: 0, + title: const Text("Today's logs"), + ), + body: _getBody(), + ); + } + + Widget _getBody() { + if (_logs == null) { + return const EnteLoadingWidget(); + } + return Container( + padding: const EdgeInsets.only(left: 12, top: 8, right: 12), + child: SingleChildScrollView( + child: Text( + _logs, + style: const TextStyle( + fontFeatures: [ + FontFeature.tabularFigures(), + ], + height: 1.2, + ), + ), + ), + ); + } +} diff --git a/lib/ui/tools/lock_screen.dart b/lib/ui/tools/lock_screen.dart new file mode 100644 index 000000000..4a4209fff --- /dev/null +++ b/lib/ui/tools/lock_screen.dart @@ -0,0 +1,71 @@ +// @dart=2.9 + +import 'package:ente_auth/ui/common/gradient_button.dart'; +import 'package:ente_auth/ui/tools/app_lock.dart'; +import 'package:ente_auth/utils/auth_util.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; + +class LockScreen extends StatefulWidget { + const LockScreen({Key key}) : super(key: key); + + @override + State createState() => _LockScreenState(); +} + +class _LockScreenState extends State { + final _logger = Logger("LockScreen"); + + @override + void initState() { + _showLockScreen(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Stack( + alignment: Alignment.center, + children: [ + Opacity( + opacity: 0.2, + child: Image.asset('assets/loading_photos_background.png'), + ), + SizedBox( + width: 142, + child: GradientButton( + text: "Unlock", + iconData: Icons.lock_open_outlined, + onTap: () async { + _showLockScreen(); + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Future _showLockScreen() async { + _logger.info("Showing lockscreen"); + try { + final result = await requestAuthentication( + "Please authenticate to view your memories", + ); + if (result) { + AppLock.of(context).didUnlock(); + } + } catch (e, s) { + _logger.severe(e, s); + } + } +} diff --git a/lib/utils/auth_util.dart b/lib/utils/auth_util.dart new file mode 100644 index 000000000..808a88c09 --- /dev/null +++ b/lib/utils/auth_util.dart @@ -0,0 +1,24 @@ +import 'package:local_auth/auth_strings.dart'; +import 'package:local_auth/local_auth.dart'; +import 'package:logging/logging.dart'; + +Future requestAuthentication(String reason) async { + Logger("AuthUtil").info("Requesting authentication"); + await LocalAuthentication().stopAuthentication(); + return await LocalAuthentication().authenticate( + localizedReason: reason, + androidAuthStrings: const AndroidAuthMessages( + biometricHint: "Verify identity", + biometricNotRecognized: "Not recognized, try again", + biometricRequiredTitle: "Biometric required", + biometricSuccess: "Successfully verified", + cancelButton: "Cancel", + deviceCredentialsRequiredTitle: "Device credentials required", + deviceCredentialsSetupDescription: "Device credentials required", + goToSettingsButton: "Go to settings", + goToSettingsDescription: + "Authentication is not setup on your device, go to Settings > Security to set it up", + signInTitle: "Authentication required", + ), + ); +} diff --git a/lib/utils/crypto_util.dart b/lib/utils/crypto_util.dart new file mode 100644 index 000000000..635b0e376 --- /dev/null +++ b/lib/utils/crypto_util.dart @@ -0,0 +1,332 @@ +import 'dart:io' as io; +import 'dart:typed_data'; + +import 'package:computer/computer.dart'; +import 'package:ente_auth/models/derived_key_result.dart'; +import 'package:ente_auth/models/encryption_result.dart'; +import 'package:flutter_sodium/flutter_sodium.dart'; +import 'package:logging/logging.dart'; + +const int encryptionChunkSize = 4 * 1024 * 1024; +final int decryptionChunkSize = + encryptionChunkSize + Sodium.cryptoSecretstreamXchacha20poly1305Abytes; + +Uint8List cryptoSecretboxEasy(Map args) { + return Sodium.cryptoSecretboxEasy(args["source"], args["nonce"], args["key"]); +} + +Uint8List cryptoSecretboxOpenEasy(Map args) { + return Sodium.cryptoSecretboxOpenEasy( + args["cipher"], + args["nonce"], + args["key"], + ); +} + +Uint8List cryptoPwHash(Map args) { + return Sodium.cryptoPwhash( + Sodium.cryptoSecretboxKeybytes, + args["password"], + args["salt"], + args["opsLimit"], + args["memLimit"], + Sodium.cryptoPwhashAlgDefault, + ); +} + +Uint8List cryptoGenericHash(Map args) { + final sourceFile = io.File(args["sourceFilePath"]); + final sourceFileLength = sourceFile.lengthSync(); + final inputFile = sourceFile.openSync(mode: io.FileMode.read); + final state = + Sodium.cryptoGenerichashInit(null, Sodium.cryptoGenerichashBytesMax); + var bytesRead = 0; + bool isDone = false; + while (!isDone) { + var chunkSize = encryptionChunkSize; + if (bytesRead + chunkSize >= sourceFileLength) { + chunkSize = sourceFileLength - bytesRead; + isDone = true; + } + final buffer = inputFile.readSync(chunkSize); + bytesRead += chunkSize; + Sodium.cryptoGenerichashUpdate(state, buffer); + } + inputFile.closeSync(); + return Sodium.cryptoGenerichashFinal(state, Sodium.cryptoGenerichashBytesMax); +} + +EncryptionResult chachaEncryptData(Map args) { + final initPushResult = + Sodium.cryptoSecretstreamXchacha20poly1305InitPush(args["key"]); + final encryptedData = Sodium.cryptoSecretstreamXchacha20poly1305Push( + initPushResult.state, + args["source"], + null, + Sodium.cryptoSecretstreamXchacha20poly1305TagFinal, + ); + return EncryptionResult( + encryptedData: encryptedData, + header: initPushResult.header, + ); +} + +Future chachaEncryptFile(Map args) async { + final encryptionStartTime = DateTime.now().millisecondsSinceEpoch; + final logger = Logger("ChaChaEncrypt"); + final sourceFile = io.File(args["sourceFilePath"]); + final destinationFile = io.File(args["destinationFilePath"]); + final sourceFileLength = await sourceFile.length(); + logger.info("Encrypting file of size " + sourceFileLength.toString()); + + final inputFile = sourceFile.openSync(mode: io.FileMode.read); + final key = args["key"] ?? Sodium.cryptoSecretstreamXchacha20poly1305Keygen(); + final initPushResult = + Sodium.cryptoSecretstreamXchacha20poly1305InitPush(key); + var bytesRead = 0; + var tag = Sodium.cryptoSecretstreamXchacha20poly1305TagMessage; + while (tag != Sodium.cryptoSecretstreamXchacha20poly1305TagFinal) { + var chunkSize = encryptionChunkSize; + if (bytesRead + chunkSize >= sourceFileLength) { + chunkSize = sourceFileLength - bytesRead; + tag = Sodium.cryptoSecretstreamXchacha20poly1305TagFinal; + } + final buffer = inputFile.readSync(chunkSize); + bytesRead += chunkSize; + final encryptedData = Sodium.cryptoSecretstreamXchacha20poly1305Push( + initPushResult.state, + buffer, + null, + tag, + ); + await destinationFile.writeAsBytes(encryptedData, mode: io.FileMode.append); + } + inputFile.closeSync(); + + logger.info( + "Encryption time: " + + (DateTime.now().millisecondsSinceEpoch - encryptionStartTime) + .toString(), + ); + + return EncryptionResult(key: key, header: initPushResult.header); +} + +Future chachaDecryptFile(Map args) async { + final logger = Logger("ChaChaDecrypt"); + final decryptionStartTime = DateTime.now().millisecondsSinceEpoch; + final sourceFile = io.File(args["sourceFilePath"]); + final destinationFile = io.File(args["destinationFilePath"]); + final sourceFileLength = await sourceFile.length(); + logger.info("Decrypting file of size " + sourceFileLength.toString()); + + final inputFile = sourceFile.openSync(mode: io.FileMode.read); + final pullState = Sodium.cryptoSecretstreamXchacha20poly1305InitPull( + args["header"], + args["key"], + ); + + var bytesRead = 0; + var tag = Sodium.cryptoSecretstreamXchacha20poly1305TagMessage; + while (tag != Sodium.cryptoSecretstreamXchacha20poly1305TagFinal) { + var chunkSize = decryptionChunkSize; + if (bytesRead + chunkSize >= sourceFileLength) { + chunkSize = sourceFileLength - bytesRead; + } + final buffer = inputFile.readSync(chunkSize); + bytesRead += chunkSize; + final pullResult = + Sodium.cryptoSecretstreamXchacha20poly1305Pull(pullState, buffer, null); + await destinationFile.writeAsBytes(pullResult.m, mode: io.FileMode.append); + tag = pullResult.tag; + } + inputFile.closeSync(); + + logger.info( + "ChaCha20 Decryption time: " + + (DateTime.now().millisecondsSinceEpoch - decryptionStartTime) + .toString(), + ); +} + +Uint8List chachaDecryptData(Map args) { + final pullState = Sodium.cryptoSecretstreamXchacha20poly1305InitPull( + args["header"], + args["key"], + ); + final pullResult = Sodium.cryptoSecretstreamXchacha20poly1305Pull( + pullState, + args["source"], + null, + ); + return pullResult.m; +} + +class CryptoUtil { + static final Computer _computer = Computer(); + + static init() { + _computer.turnOn(workersCount: 4); + // Sodium.init(); + } + + static EncryptionResult encryptSync(Uint8List source, Uint8List key) { + final nonce = Sodium.randombytesBuf(Sodium.cryptoSecretboxNoncebytes); + + final args = {}; + args["source"] = source; + args["nonce"] = nonce; + args["key"] = key; + final encryptedData = cryptoSecretboxEasy(args); + return EncryptionResult( + key: key, + nonce: nonce, + encryptedData: encryptedData, + ); + } + + static Future decrypt( + Uint8List cipher, + Uint8List key, + Uint8List nonce, + ) async { + final args = {}; + args["cipher"] = cipher; + args["nonce"] = nonce; + args["key"] = key; + return _computer.compute(cryptoSecretboxOpenEasy, param: args); + } + + static Uint8List decryptSync( + Uint8List cipher, + Uint8List? key, + Uint8List nonce, + ) { + assert(key != null, "key can not be null"); + final args = {}; + args["cipher"] = cipher; + args["nonce"] = nonce; + args["key"] = key; + return cryptoSecretboxOpenEasy(args); + } + + static Future encryptChaCha( + Uint8List source, + Uint8List key, + ) async { + final args = {}; + args["source"] = source; + args["key"] = key; + return _computer.compute(chachaEncryptData, param: args); + } + + static Future decryptChaCha( + Uint8List source, + Uint8List key, + Uint8List header, + ) async { + final args = {}; + args["source"] = source; + args["key"] = key; + args["header"] = header; + return _computer.compute(chachaDecryptData, param: args); + } + + static Future encryptFile( + String sourceFilePath, + String destinationFilePath, { + Uint8List? key, + }) { + final args = {}; + args["sourceFilePath"] = sourceFilePath; + args["destinationFilePath"] = destinationFilePath; + args["key"] = key; + return _computer.compute(chachaEncryptFile, param: args); + } + + static Future decryptFile( + String sourceFilePath, + String destinationFilePath, + Uint8List header, + Uint8List key, + ) { + final args = {}; + args["sourceFilePath"] = sourceFilePath; + args["destinationFilePath"] = destinationFilePath; + args["header"] = header; + args["key"] = key; + return _computer.compute(chachaDecryptFile, param: args); + } + + static Uint8List generateKey() { + return Sodium.cryptoSecretboxKeygen(); + } + + static Uint8List getSaltToDeriveKey() { + return Sodium.randombytesBuf(Sodium.cryptoPwhashSaltbytes); + } + + static Future generateKeyPair() async { + return Sodium.cryptoBoxKeypair(); + } + + static Uint8List openSealSync( + Uint8List input, + Uint8List publicKey, + Uint8List secretKey, + ) { + return Sodium.cryptoBoxSealOpen(input, publicKey, secretKey); + } + + static Uint8List sealSync(Uint8List input, Uint8List publicKey) { + return Sodium.cryptoBoxSeal(input, publicKey); + } + + static Future deriveSensitiveKey( + Uint8List password, + Uint8List salt, + ) async { + final logger = Logger("pwhash"); + int memLimit = Sodium.cryptoPwhashMemlimitSensitive; + int opsLimit = Sodium.cryptoPwhashOpslimitSensitive; + Uint8List key; + while (memLimit > Sodium.cryptoPwhashMemlimitMin && + opsLimit < Sodium.cryptoPwhashOpslimitMax) { + try { + key = await deriveKey(password, salt, memLimit, opsLimit); + return DerivedKeyResult(key, memLimit, opsLimit); + } catch (e, s) { + logger.severe(e, s); + } + memLimit = (memLimit / 2).round(); + opsLimit = opsLimit * 2; + } + throw UnsupportedError("Cannot perform this operation on this device"); + } + + static Future deriveKey( + Uint8List password, + Uint8List salt, + int memLimit, + int opsLimit, + ) { + return _computer.compute( + cryptoPwHash, + param: { + "password": password, + "salt": salt, + "memLimit": memLimit, + "opsLimit": opsLimit, + }, + ); + } + + static Future getHash(io.File source) { + return _computer.compute( + cryptoGenericHash, + param: { + "sourceFilePath": source.path, + }, + ); + } +} diff --git a/lib/utils/data_util.dart b/lib/utils/data_util.dart new file mode 100644 index 000000000..3dcae58fa --- /dev/null +++ b/lib/utils/data_util.dart @@ -0,0 +1,26 @@ +import 'dart:math'; + +double convertBytesToGBs(final int bytes, {int precision = 2}) { + return double.parse( + (bytes / (1024 * 1024 * 1024)).toStringAsFixed(precision), + ); +} + +final storageUnits = ["bytes", "KB", "MB", "GB"]; + +String convertBytesToReadableFormat(int bytes) { + int storageUnitIndex = 0; + while (bytes >= 1024 && storageUnitIndex < storageUnits.length - 1) { + storageUnitIndex++; + bytes = (bytes / 1024).round(); + } + return bytes.toString() + " " + storageUnits[storageUnitIndex]; +} + +String formatBytes(int bytes, [int decimals = 2]) { + if (bytes == 0) return '0 bytes'; + const k = 1024; + final int dm = decimals < 0 ? 0 : decimals; + final int i = (log(bytes) / log(k)).floor(); + return ((bytes / pow(k, i)).toStringAsFixed(dm)) + ' ' + storageUnits[i]; +} diff --git a/lib/utils/date_time_util.dart b/lib/utils/date_time_util.dart new file mode 100644 index 000000000..13688ebd9 --- /dev/null +++ b/lib/utils/date_time_util.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +const Set monthWith31Days = {1, 3, 5, 7, 8, 10, 12}; +const Set monthWith30Days = {4, 6, 9, 11}; +Map _months = { + 1: "Jan", + 2: "Feb", + 3: "March", + 4: "April", + 5: "May", + 6: "Jun", + 7: "July", + 8: "Aug", + 9: "Sep", + 10: "Oct", + 11: "Nov", + 12: "Dec", +}; + +Map _fullMonths = { + 1: "January", + 2: "February", + 3: "March", + 4: "April", + 5: "May", + 6: "June", + 7: "July", + 8: "August", + 9: "September", + 10: "October", + 11: "November", + 12: "December", +}; + +Map _days = { + 1: "Mon", + 2: "Tue", + 3: "Wed", + 4: "Thu", + 5: "Fri", + 6: "Sat", + 7: "Sun", +}; + +final currentYear = int.parse(DateTime.now().year.toString()); +const searchStartYear = 1970; + +//Jun 2022 +String getMonthAndYear(DateTime dateTime) { + return _months[dateTime.month]! + " " + dateTime.year.toString(); +} + +//Thu, 30 Jun +String getDayAndMonth(DateTime dateTime) { + return _days[dateTime.weekday]! + + ", " + + dateTime.day.toString() + + " " + + _months[dateTime.month]!; +} + +//30 Jun, 2022 +String getDateAndMonthAndYear(DateTime dateTime) { + return dateTime.day.toString() + + " " + + _months[dateTime.month]! + + ", " + + dateTime.year.toString(); +} + +String getDay(DateTime dateTime) { + return _days[dateTime.weekday]!; +} + +String getMonth(DateTime dateTime) { + return _months[dateTime.month]!; +} + +String getFullMonth(DateTime dateTime) { + return _fullMonths[dateTime.month]!; +} + +String getAbbreviationOfYear(DateTime dateTime) { + return (dateTime.year % 100).toString(); +} + +//14:32 +String getTime(DateTime dateTime) { + final hours = dateTime.hour > 9 + ? dateTime.hour.toString() + : "0" + dateTime.hour.toString(); + final minutes = dateTime.minute > 9 + ? dateTime.minute.toString() + : "0" + dateTime.minute.toString(); + return hours + ":" + minutes; +} + +//11:22 AM +String getTimeIn12hrFormat(DateTime dateTime) { + return DateFormat.jm().format(dateTime); +} + +//Thu, Jun 30, 2022 - 14:32 +String getFormattedTime(DateTime dateTime) { + return getDay(dateTime) + + ", " + + getMonth(dateTime) + + " " + + dateTime.day.toString() + + ", " + + dateTime.year.toString() + + " - " + + getTime(dateTime); +} + +//30 Jun'22 +String getFormattedDate(DateTime dateTime) { + return dateTime.day.toString() + + " " + + getMonth(dateTime) + + "'" + + getAbbreviationOfYear(dateTime); +} + +String getFullDate(DateTime dateTime) { + return getDay(dateTime) + + ", " + + getMonth(dateTime) + + " " + + dateTime.day.toString() + + " " + + dateTime.year.toString(); +} + +String daysLeft(int futureTime) { + final int daysLeft = ((futureTime - DateTime.now().microsecondsSinceEpoch) / + Duration.microsecondsPerDay) + .ceil(); + return '$daysLeft day' + (daysLeft <= 1 ? "" : "s"); +} + +String formatDuration(Duration position) { + final ms = position.inMilliseconds; + + int seconds = ms ~/ 1000; + final int hours = seconds ~/ 3600; + seconds = seconds % 3600; + final minutes = seconds ~/ 60; + seconds = seconds % 60; + + final hoursString = hours >= 10 + ? '$hours' + : hours == 0 + ? '00' + : '0$hours'; + + final minutesString = minutes >= 10 + ? '$minutes' + : minutes == 0 + ? '00' + : '0$minutes'; + + final secondsString = seconds >= 10 + ? '$seconds' + : seconds == 0 + ? '00' + : '0$seconds'; + + final formattedTime = + '${hoursString == '00' ? '' : hoursString + ':'}$minutesString:$secondsString'; + + return formattedTime; +} + +bool isLeapYear(DateTime dateTime) { + final year = dateTime.year; + if (year % 4 == 0) { + if (year % 100 == 0) { + if (year % 400 == 0) { + return true; + } else { + return false; + } + } else { + return true; + } + } else { + return false; + } +} + +Widget getDayWidget( + BuildContext context, + int timestamp, + bool smallerTodayFont, +) { + return Container( + padding: const EdgeInsets.fromLTRB(4, 14, 0, 8), + alignment: Alignment.centerLeft, + child: Text( + getDayTitle(timestamp), + style: (getDayTitle(timestamp) == "Today" && !smallerTodayFont) + ? Theme.of(context).textTheme.headline5 + : Theme.of(context).textTheme.caption?.copyWith( + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Inter-SemiBold', + ), + ), + ); +} + +String getDayTitle(int timestamp) { + final date = DateTime.fromMicrosecondsSinceEpoch(timestamp); + final now = DateTime.now(); + var title = getDayAndMonth(date); + if (date.year == now.year && date.month == now.month) { + if (date.day == now.day) { + title = "Today"; + } else if (date.day == now.day - 1) { + title = "Yesterday"; + } + } + if (date.year != DateTime.now().year) { + title += " " + date.year.toString(); + } + return title; +} + +String secondsToHHMMSS(int value) { + int h, m, s; + h = value ~/ 3600; + m = ((value - h * 3600)) ~/ 60; + s = value - (h * 3600) - (m * 60); + final String hourLeft = + h.toString().length < 2 ? "0" + h.toString() : h.toString(); + + final String minuteLeft = + m.toString().length < 2 ? "0" + m.toString() : m.toString(); + + final String secondsLeft = + s.toString().length < 2 ? "0" + s.toString() : s.toString(); + + final String result = "$hourLeft:$minuteLeft:$secondsLeft"; + + return result; +} + +bool isValidDate({ + required int day, + required int month, + required int year, +}) { + if (day < 0 || day > 31 || month < 0 || month > 12 || year < 0) { + return false; + } + if (monthWith30Days.contains(month) && day > 30) { + return false; + } + if (month == 2) { + if (day > 29) { + return false; + } + if (day == 29 && year % 4 != 0) { + return false; + } + } + return true; +} diff --git a/lib/utils/dialog_util.dart b/lib/utils/dialog_util.dart new file mode 100644 index 000000000..0e4f3003a --- /dev/null +++ b/lib/utils/dialog_util.dart @@ -0,0 +1,115 @@ +// @dart=2.9 + +import 'dart:math'; + +import 'package:confetti/confetti.dart'; +import 'package:ente_auth/ui/common/loading_widget.dart'; +import 'package:ente_auth/ui/common/progress_dialog.dart'; +import 'package:flutter/material.dart'; + +ProgressDialog createProgressDialog(BuildContext context, String message) { + final dialog = ProgressDialog( + context, + type: ProgressDialogType.normal, + isDismissible: false, + barrierColor: Colors.black12, + ); + dialog.style( + message: message, + messageTextStyle: + Theme.of(context).textTheme.caption.copyWith(fontSize: 14), + backgroundColor: Theme.of(context).dialogTheme.backgroundColor, + progressWidget: const EnteLoadingWidget(), + borderRadius: 10, + elevation: 10.0, + insetAnimCurve: Curves.easeInOut, + ); + return dialog; +} + +Future showErrorDialog( + BuildContext context, + String title, + String content, +) { + final AlertDialog alert = AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + title: title.isEmpty + ? const SizedBox.shrink() + : Text( + title, + style: Theme.of(context).textTheme.headline6, + ), + content: Text(content), + actions: [ + TextButton( + child: Text( + "Ok", + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop('dialog'); + }, + ), + ], + ); + + return showDialog( + context: context, + builder: (BuildContext context) { + return alert; + }, + barrierColor: Colors.black12, + ); +} + +Future showGenericErrorDialog(BuildContext context) { + return showErrorDialog(context, "Something went wrong", "Please try again."); +} + +Future showConfettiDialog({ + @required BuildContext context, + WidgetBuilder builder, + bool barrierDismissible = true, + Color barrierColor, + bool useSafeArea = true, + bool useRootNavigator = true, + RouteSettings routeSettings, + Alignment confettiAlignment = Alignment.center, +}) { + final pageBuilder = Builder( + builder: builder, + ); + final ConfettiController confettiController = + ConfettiController(duration: const Duration(seconds: 1)); + confettiController.play(); + return showDialog( + context: context, + builder: (BuildContext buildContext) { + return Stack( + children: [ + pageBuilder, + Align( + alignment: confettiAlignment, + child: ConfettiWidget( + confettiController: confettiController, + blastDirection: pi / 2, + emissionFrequency: 0, + numberOfParticles: 100, + // a lot of particles at once + gravity: 1, + blastDirectionality: BlastDirectionality.explosive, + ), + ), + ], + ); + }, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + useSafeArea: useSafeArea, + useRootNavigator: useRootNavigator, + routeSettings: routeSettings, + ); +} diff --git a/lib/utils/email_util.dart b/lib/utils/email_util.dart new file mode 100644 index 000000000..dcdc035fc --- /dev/null +++ b/lib/utils/email_util.dart @@ -0,0 +1,259 @@ +// @dart=2.9 + +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:email_validator/email_validator.dart'; +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/ui/common/dialogs.dart'; +// import 'package:ente_auth/ui/tools/debug/log_file_viewer.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/toast_util.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_email_sender/flutter_email_sender.dart'; +import 'package:logging/logging.dart'; +// import 'package:open_mail_app/open_mail_app.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +final Logger _logger = Logger('email_util'); + +bool isValidEmail(String email) { + return EmailValidator.validate(email); +} + +Future sendLogs( + BuildContext context, + String title, + String toEmail, { + Function postShare, + String subject, + String body, +}) async { + final List actions = [ + TextButton( + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon( + Icons.feed_outlined, + color: Theme.of(context).iconTheme.color.withOpacity(0.85), + ), + const Padding(padding: EdgeInsets.all(4)), + Text( + "View logs", + style: TextStyle( + color: Theme.of(context) + .colorScheme + .defaultTextColor + .withOpacity(0.85), + ), + ), + ], + ), + onPressed: () async { + showDialog( + context: context, + builder: (BuildContext context) { + // return LogFileViewer(SuperLogging.logFile); + }, + barrierColor: Colors.black87, + barrierDismissible: false, + ); + }, + ), + TextButton( + child: Text( + title, + style: TextStyle( + color: Theme.of(context).colorScheme.alternativeColor, + ), + ), + onPressed: () async { + Navigator.of(context, rootNavigator: true).pop('dialog'); + await _sendLogs(context, toEmail, subject, body); + if (postShare != null) { + postShare(); + } + }, + ), + ]; + final List content = []; + content.addAll( + [ + const Text( + "This will send across logs to help us debug your issue. Please note that file names will be included to help track issues with specific files.", + style: TextStyle( + height: 1.5, + fontSize: 16, + ), + ), + const Padding(padding: EdgeInsets.all(12)), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: actions, + ), + ], + ); + final confirmation = AlertDialog( + title: Text( + title, + style: const TextStyle( + fontSize: 18, + ), + ), + content: SingleChildScrollView( + child: ListBody( + children: content, + ), + ), + ); + showDialog( + context: context, + builder: (_) { + return confirmation; + }, + ); +} + +Future _sendLogs( + BuildContext context, + String toEmail, + String subject, + String body, +) async { + final String zipFilePath = await getZippedLogsFile(context); + final Email email = Email( + recipients: [toEmail], + subject: subject, + body: body, + attachmentPaths: [zipFilePath], + isHTML: false, + ); + try { + await FlutterEmailSender.send(email); + } catch (e, s) { + _logger.severe('email sender failed', e, s); + await shareLogs(context, toEmail, zipFilePath); + } +} + +Future getZippedLogsFile(BuildContext context) async { + final dialog = createProgressDialog(context, "Preparing logs..."); + await dialog.show(); + final logsPath = (await getApplicationSupportDirectory()).path; + final logsDirectory = Directory(logsPath + "/logs"); + final tempPath = (await getTemporaryDirectory()).path; + final zipFilePath = + tempPath + "/logs-${Configuration.instance.getUserID() ?? 0}.zip"; + final encoder = ZipFileEncoder(); + encoder.create(zipFilePath); + encoder.addDirectory(logsDirectory); + encoder.close(); + await dialog.hide(); + return zipFilePath; +} + +Future shareLogs( + BuildContext context, + String toEmail, + String zipFilePath, +) async { + final result = await showChoiceDialog( + context, + "Email logs", + "Please send the logs to $toEmail", + firstAction: "Copy email", + secondAction: "Export logs", + ); + if (result != null && result == DialogUserChoice.firstChoice) { + await Clipboard.setData(ClipboardData(text: toEmail)); + } + final Size size = MediaQuery.of(context).size; + await Share.shareFiles( + [zipFilePath], + sharePositionOrigin: Rect.fromLTWH(0, 0, size.width, size.height / 2), + ); +} + +Future sendEmail( + BuildContext context, { + @required String to, + String subject, + String body, +}) async { + try { + final String clientDebugInfo = await _clientInfo(); + final String _subject = subject ?? '[Support]'; + final String _body = (body ?? '') + clientDebugInfo; + // final EmailContent email = EmailContent( + // to: [ + // to, + // ], + // subject: subject ?? '[Support]', + // body: (body ?? '') + clientDebugInfo, + // ); + if (Platform.isAndroid) { + // Special handling due to issue in proton mail android client + // https://github.com/ente-io/frame/pull/253 + final Uri params = Uri( + scheme: 'mailto', + path: to, + query: 'subject=$_subject&body=${_body}', + ); + if (await canLaunchUrl(params)) { + await launchUrl(params); + } else { + // this will trigger _showNoMailAppsDialog + throw Exception('Could not launch ${params.toString()}'); + } + } else { + _showNoMailAppsDialog(context, to); + } + } catch (e) { + _logger.severe("Failed to send email to $to", e); + _showNoMailAppsDialog(context, to); + } +} + +Future _clientInfo() async { + final packageInfo = await PackageInfo.fromPlatform(); + final String debugInfo = + '\n\n\n\n ------------------- \nFollowing information can ' + 'help us in debugging if you are facing any issue ' + '\nRegistered email: ${Configuration.instance.getEmail()}' + '\nClient: ${packageInfo.packageName}' + '\nVersion : ${packageInfo.version}'; + return debugInfo; +} + +void _showNoMailAppsDialog(BuildContext context, String toEmail) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Please email us at $toEmail'), + actions: [ + TextButton( + child: const Text("Copy email"), + onPressed: () async { + await Clipboard.setData(ClipboardData(text: toEmail)); + showShortToast(context, 'Copied'); + }, + ), + TextButton( + child: const Text("OK"), + onPressed: () { + Navigator.pop(context); + }, + ) + ], + ); + }, + ); +} diff --git a/lib/utils/navigation_util.dart b/lib/utils/navigation_util.dart new file mode 100644 index 000000000..38a3dbe53 --- /dev/null +++ b/lib/utils/navigation_util.dart @@ -0,0 +1,149 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +Future routeToPage( + BuildContext context, + Widget page, { + bool forceCustomPageRoute = false, +}) { + if (Platform.isAndroid || forceCustomPageRoute) { + return Navigator.of(context).push( + _buildPageRoute(page), + ); + } else { + return Navigator.of(context).push( + SwipeableRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) { + return page; + }, + ), + ); + } +} + +void replacePage(BuildContext context, Widget page) { + Navigator.of(context).pushReplacement( + _buildPageRoute(page), + ); +} + +PageRouteBuilder _buildPageRoute(Widget page) { + return PageRouteBuilder( + pageBuilder: ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return page; + }, + transitionsBuilder: ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return Align( + child: FadeTransition( + opacity: animation, + child: child, + ), + ); + }, + transitionDuration: const Duration(milliseconds: 200), + opaque: false, + ); +} + +class SwipeableRouteBuilder extends PageRoute { + final RoutePageBuilder pageBuilder; + final PageTransitionsBuilder matchingBuilder = + const CupertinoPageTransitionsBuilder(); // Default iOS/macOS (to get the swipe right to go back gesture) + // final PageTransitionsBuilder matchingBuilder = const FadeUpwardsPageTransitionsBuilder(); // Default Android/Linux/Windows + + SwipeableRouteBuilder({required this.pageBuilder}); + + @override + Null get barrierColor => null; + + @override + Null get barrierLabel => null; + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return pageBuilder(context, animation, secondaryAnimation); + } + + @override + bool get maintainState => true; + + @override + Duration get transitionDuration => const Duration( + milliseconds: 300, + ); // Can give custom Duration, unlike in MaterialPageRoute + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return matchingBuilder.buildTransitions( + this, + context, + animation, + secondaryAnimation, + child, + ); + } + + @override + bool get opaque => false; +} + +class TransparentRoute extends PageRoute { + TransparentRoute({ + required this.builder, + RouteSettings? settings, + }) : assert(builder != null), + super(settings: settings, fullscreenDialog: false); + + final WidgetBuilder? builder; + + @override + bool get opaque => false; + + @override + Null get barrierColor => null; + + @override + Null get barrierLabel => null; + + @override + bool get maintainState => true; + + @override + Duration get transitionDuration => const Duration(milliseconds: 200); + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + final result = builder!(context); + return FadeTransition( + opacity: Tween(begin: 0, end: 1).animate(animation), + child: Semantics( + scopesRoute: true, + explicitChildNodes: true, + child: result, + ), + ); + } +} diff --git a/lib/utils/toast_util.dart b/lib/utils/toast_util.dart new file mode 100644 index 000000000..972e9971a --- /dev/null +++ b/lib/utils/toast_util.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:fluttertoast/fluttertoast.dart'; + +Future showToast( + BuildContext context, + String message, { + toastLength = Toast.LENGTH_LONG, + iOSDismissOnTap = true, +}) async { + if (Platform.isAndroid) { + await Fluttertoast.cancel(); + return Fluttertoast.showToast( + msg: message, + toastLength: toastLength, + gravity: ToastGravity.BOTTOM, + timeInSecForIosWeb: 1, + backgroundColor: Theme.of(context).colorScheme.toastBackgroundColor, + textColor: Theme.of(context).colorScheme.toastTextColor, + fontSize: 16.0, + ); + } else { + EasyLoading.instance + ..backgroundColor = Theme.of(context).colorScheme.toastBackgroundColor + ..indicatorColor = Theme.of(context).colorScheme.toastBackgroundColor + ..textColor = Theme.of(context).colorScheme.toastTextColor + ..userInteractions = true + ..loadingStyle = EasyLoadingStyle.custom; + return EasyLoading.showToast( + message, + duration: Duration(seconds: (toastLength == Toast.LENGTH_LONG ? 5 : 2)), + toastPosition: EasyLoadingToastPosition.bottom, + dismissOnTap: iOSDismissOnTap, + ); + } +} + +Future showShortToast(context, String message) { + return showToast(context, message, toastLength: Toast.LENGTH_SHORT); +} diff --git a/lib/utils/totp_util.dart b/lib/utils/totp_util.dart new file mode 100644 index 000000000..28c8d7d88 --- /dev/null +++ b/lib/utils/totp_util.dart @@ -0,0 +1,35 @@ +import 'package:ente_auth/models/code.dart'; +import 'package:otp/otp.dart' as otp; + +String getTotp(Code code) { + return otp.OTP.generateTOTPCodeString( + code.secret, + DateTime.now().millisecondsSinceEpoch, + length: code.digits, + interval: code.period, + algorithm: _getAlgorithm(code), + isGoogle: true, + ); +} + +String getNextTotp(Code code) { + return otp.OTP.generateTOTPCodeString( + code.secret, + DateTime.now().millisecondsSinceEpoch + code.period * 1000, + length: code.digits, + interval: code.period, + algorithm: _getAlgorithm(code), + isGoogle: true, + ); +} + +otp.Algorithm _getAlgorithm(Code code) { + switch (code.algorithm) { + case Algorithm.sha256: + return otp.Algorithm.SHA256; + case Algorithm.sha512: + return otp.Algorithm.SHA512; + default: + return otp.Algorithm.SHA1; + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 000000000..d3896c984 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 000000000..d4bda73c2 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,138 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "authenticator") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "io.ente.authenticator") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation ui.settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build ui.settings. This can be removed for applications +# that need different build ui.settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 000000000..d5bd01648 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..38dd0bc6c --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..e0f0a47bc --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 000000000..65240e99c --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/main.cc b/linux/main.cc new file mode 100644 index 000000000..e7c5c5437 --- /dev/null +++ b/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/my_application.cc b/linux/my_application.cc new file mode 100644 index 000000000..95b3a369c --- /dev/null +++ b/linux/my_application.cc @@ -0,0 +1,104 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "authenticator"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "authenticator"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/my_application.h b/linux/my_application.h new file mode 100644 index 000000000..72271d5e4 --- /dev/null +++ b/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 000000000..746adbb6b --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 000000000..4b81f9b2d --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 000000000..5caa9d157 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 000000000..657d80049 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,26 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import connectivity_macos +import flutter_secure_storage_macos +import package_info_plus_macos +import path_provider_macos +import share_plus_macos +import shared_preferences_macos +import sqflite +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin")) + FlutterSecureStorageMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageMacosPlugin")) + FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 000000000..dade8dfad --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.11' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 000000000..8a7215a33 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,77 @@ +PODS: + - connectivity_macos (0.0.1): + - FlutterMacOS + - Reachability + - flutter_secure_storage_macos (3.3.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - FMDB (2.7.5): + - FMDB/standard (= 2.7.5) + - FMDB/standard (2.7.5) + - package_info_plus_macos (0.0.1): + - FlutterMacOS + - path_provider_macos (0.0.1): + - FlutterMacOS + - Reachability (3.2) + - share_plus_macos (0.0.1): + - FlutterMacOS + - shared_preferences_macos (0.0.1): + - FlutterMacOS + - sqflite (0.0.2): + - FlutterMacOS + - FMDB (>= 2.7.5) + - url_launcher_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - connectivity_macos (from `Flutter/ephemeral/.symlinks/plugins/connectivity_macos/macos`) + - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - package_info_plus_macos (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus_macos/macos`) + - path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`) + - share_plus_macos (from `Flutter/ephemeral/.symlinks/plugins/share_plus_macos/macos`) + - shared_preferences_macos (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos`) + - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/macos`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +SPEC REPOS: + trunk: + - FMDB + - Reachability + +EXTERNAL SOURCES: + connectivity_macos: + :path: Flutter/ephemeral/.symlinks/plugins/connectivity_macos/macos + flutter_secure_storage_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos + FlutterMacOS: + :path: Flutter/ephemeral + package_info_plus_macos: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus_macos/macos + path_provider_macos: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos + share_plus_macos: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus_macos/macos + shared_preferences_macos: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos + sqflite: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite/macos + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + +SPEC CHECKSUMS: + connectivity_macos: 5dae6ee11d320fac7c05f0d08bd08fc32b5514d9 + flutter_secure_storage_macos: 6ceee8fbc7f484553ad17f79361b556259df89aa + FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811 + FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a + package_info_plus_macos: f010621b07802a241d96d01876d6705f15e77c1c + path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19 + Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96 + share_plus_macos: 853ee48e7dce06b633998ca0735d482dd671ade4 + shared_preferences_macos: a64dc611287ed6cbe28fd1297898db1336975727 + sqflite: a5789cceda41d54d23f31d6de539d65bb14100ea + url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3 + +PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c + +COCOAPODS: 1.11.3 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000..cab12ae2c --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,632 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 51; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + A7AB73983D83D29CAED87538 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 82E030F365AB53DD3540E157 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* authenticator.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = authenticator.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 4F2F733D93DB4D2D82767271 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 82E030F365AB53DD3540E157 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 93F9AF5444709A89957CF0B1 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + B347CC163E4E13C897729F91 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A7AB73983D83D29CAED87538 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 313014E1EF861E1D898002E0 /* Pods */ = { + isa = PBXGroup; + children = ( + 93F9AF5444709A89957CF0B1 /* Pods-Runner.debug.xcconfig */, + 4F2F733D93DB4D2D82767271 /* Pods-Runner.release.xcconfig */, + B347CC163E4E13C897729F91 /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 313014E1EF861E1D898002E0 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* authenticator.app */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 82E030F365AB53DD3540E157 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + D23871DF3B87FD7A13DA6FC6 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 49BDCC069447EC72E4744E01 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* authenticator.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1300; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 49BDCC069447EC72E4744E01 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + D23871DF3B87FD7A13DA6FC6 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 000000000..3fc3ba1d4 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..21a3cc14c --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 000000000..d53ef6437 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,9 @@ +import Cocoa +import FlutterMacOS + +@NSApplicationMain +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..a2ec33f19 --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 000000000..3c4935a7c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 000000000..ed4cc1642 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 000000000..483be6138 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 000000000..bcbf36df2 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 000000000..9c0a65286 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 000000000..e71a72613 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 000000000..8a31fe2dd Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 000000000..80e867a4e --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 000000000..ac2353469 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level ui.settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = authenticator + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = io.ente.authenticator + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright ยฉ 2022 io.ente.authenticator. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 000000000..36b0fd946 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 000000000..dff4f4956 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 000000000..42bcbf478 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 000000000..c946719a1 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 000000000..4789daa6a --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 000000000..2722837ec --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController.init() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 000000000..48271acc9 --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 000000000..945ea065e --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1319 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + url: "https://pub.dartlang.org" + source: hosted + version: "40.0.0" + adaptive_theme: + dependency: "direct main" + description: + name: adaptive_theme + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.1" + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.0" + archive: + dependency: transitive + description: + name: archive + url: "https://pub.dartlang.org" + source: hosted + version: "3.3.1" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.1" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.9.0" + base32: + dependency: transitive + description: + name: base32 + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + bip39: + dependency: "direct main" + description: + name: bip39 + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.6" + bloc: + dependency: "direct main" + description: + name: bloc + url: "https://pub.dartlang.org" + source: hosted + version: "8.0.3" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + url: "https://pub.dartlang.org" + source: hosted + version: "9.0.3" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + build: + dependency: transitive + description: + name: build + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.0" + build_config: + dependency: transitive + description: + name: build_config + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.9" + build_runner: + dependency: "direct dev" + description: + name: build_runner + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.11" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + url: "https://pub.dartlang.org" + source: hosted + version: "7.2.3" + built_collection: + dependency: transitive + description: + name: built_collection + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + url: "https://pub.dartlang.org" + source: hosted + version: "8.3.2" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + clipboard: + dependency: "direct main" + description: + name: clipboard + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.3" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + code_builder: + dependency: transitive + description: + name: code_builder + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.0" + collection: + dependency: "direct main" + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.16.0" + computer: + dependency: "direct main" + description: + name: computer + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + confetti: + dependency: "direct main" + description: + name: confetti + url: "https://pub.dartlang.org" + source: hosted + version: "0.7.0" + connectivity: + dependency: "direct main" + description: + name: connectivity + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.6" + connectivity_for_web: + dependency: transitive + description: + name: connectivity_for_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.0+1" + connectivity_macos: + dependency: transitive + description: + name: connectivity_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.1+2" + connectivity_platform_interface: + dependency: transitive + description: + name: connectivity_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + coverage: + dependency: transitive + description: + name: coverage + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.2" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + csslib: + dependency: transitive + description: + name: csslib + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + dart_style: + dependency: transitive + description: + name: dart_style + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.3" + device_info: + dependency: "direct main" + description: + name: device_info + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" + device_info_platform_interface: + dependency: transitive + description: + name: device_info_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.1" + dio: + dependency: "direct main" + description: + name: dio + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.6" + dotted_border: + dependency: "direct main" + description: + name: dotted_border + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0+2" + email_validator: + dependency: "direct main" + description: + name: email_validator + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.17" + event_bus: + dependency: "direct main" + description: + name: event_bus + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + expandable: + dependency: "direct main" + description: + name: expandable + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.1" + expansion_tile_card: + dependency: "direct main" + description: + name: expansion_tile_card + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.2" + fixnum: + dependency: transitive + description: + name: fixnum + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + fk_user_agent: + dependency: "direct main" + description: + name: fk_user_agent + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_animation_progress_bar: + dependency: "direct main" + description: + name: flutter_animation_progress_bar + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + url: "https://pub.dartlang.org" + source: hosted + version: "8.0.1" + flutter_easyloading: + dependency: "direct main" + description: + name: flutter_easyloading + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.5" + flutter_email_sender: + dependency: "direct main" + description: + name: flutter_email_sender + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.0" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview + url: "https://pub.dartlang.org" + source: hosted + version: "5.7.1" + flutter_launcher_icons: + dependency: "direct main" + description: + name: flutter_launcher_icons + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.3" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_native_splash: + dependency: "direct main" + description: + name: flutter_native_splash + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.12" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.7" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.2" + flutter_slidable: + dependency: "direct main" + description: + name: flutter_slidable + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + flutter_sodium: + dependency: "direct main" + description: + name: flutter_sodium + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" + flutter_speed_dial: + dependency: "direct main" + description: + name: flutter_speed_dial + url: "https://pub.dartlang.org" + source: hosted + version: "6.2.0" + flutter_spinkit: + dependency: transitive + description: + name: flutter_spinkit + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_windowmanager: + dependency: "direct main" + description: + name: flutter_windowmanager + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" + fluttertoast: + dependency: "direct main" + description: + name: fluttertoast + url: "https://pub.dartlang.org" + source: hosted + version: "8.0.9" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + google_nav_bar: + dependency: "direct main" + description: + name: google_nav_bar + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.6" + graphs: + dependency: transitive + description: + name: graphs + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + hex: + dependency: transitive + description: + name: hex + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" + html: + dependency: transitive + description: + name: html + url: "https://pub.dartlang.org" + source: hosted + version: "0.15.1" + http: + dependency: "direct main" + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.5" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.0" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.1" + image: + dependency: transitive + description: + name: image + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.2" + in_app_purchase: + dependency: "direct main" + description: + name: in_app_purchase + url: "https://pub.dartlang.org" + source: hosted + version: "0.5.2" + intl: + dependency: "direct main" + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.0" + io: + dependency: transitive + description: + name: io + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.4" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "4.5.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + url: "https://pub.dartlang.org" + source: hosted + version: "6.2.0" + lints: + dependency: "direct dev" + description: + name: lints + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + local_auth: + dependency: "direct main" + description: + name: local_auth + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.11" + logging: + dependency: "direct main" + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.12" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.5" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + mime: + dependency: transitive + description: + name: mime + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + mocktail: + dependency: "direct dev" + description: + name: mocktail + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" + move_to_background: + dependency: "direct main" + description: + name: move_to_background + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + nested: + dependency: transitive + description: + name: nested + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + otp: + dependency: "direct main" + description: + name: otp + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.1" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.2" + package_info_plus_linux: + dependency: transitive + description: + name: package_info_plus_linux + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + package_info_plus_macos: + dependency: transitive + description: + name: package_info_plus_macos + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + package_info_plus_web: + dependency: transitive + description: + name: package_info_plus_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + package_info_plus_windows: + dependency: transitive + description: + name: package_info_plus_windows + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + password_strength: + dependency: "direct main" + description: + name: password_strength + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.2" + path_drawing: + dependency: transitive + description: + name: path_drawing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.11" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.20" + path_provider_ios: + dependency: transitive + description: + name: path_provider_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.11" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.7" + path_provider_macos: + dependency: transitive + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.6" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.7" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.0" + pinput: + dependency: "direct main" + description: + name: pinput + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.2" + platform: + dependency: transitive + description: + name: platform + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + pointycastle: + dependency: transitive + description: + name: pointycastle + url: "https://pub.dartlang.org" + source: hosted + version: "3.6.2" + pool: + dependency: transitive + description: + name: pool + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.0" + process: + dependency: transitive + description: + name: process + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.4" + provider: + dependency: transitive + description: + name: provider + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + qr_code_scanner: + dependency: "direct main" + description: + name: qr_code_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + share_plus: + dependency: "direct main" + description: + name: share_plus + url: "https://pub.dartlang.org" + source: hosted + version: "4.4.0" + share_plus_linux: + dependency: transitive + description: + name: share_plus_linux + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + share_plus_macos: + dependency: transitive + description: + name: share_plus_macos + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.3" + share_plus_web: + dependency: transitive + description: + name: share_plus_web + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + share_plus_windows: + dependency: transitive + description: + name: share_plus_windows + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.15" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.13" + shared_preferences_ios: + dependency: transitive + description: + name: shared_preferences_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + shared_preferences_macos: + dependency: transitive + description: + name: shared_preferences_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + shelf: + dependency: transitive + description: + name: shelf + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" + shelf_static: + dependency: transitive + description: + name: shelf_static + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.2" + source_helper: + dependency: transitive + description: + name: source_helper + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.2" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + source_maps: + dependency: transitive + description: + name: source_maps + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.10" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.0" + sqflite: + dependency: "direct main" + description: + name: sqflite + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + step_progress_indicator: + dependency: "direct main" + description: + name: step_progress_indicator + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + stream_transform: + dependency: transitive + description: + name: stream_transform + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.1" + synchronized: + dependency: transitive + description: + name: synchronized + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0+3" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + test: + dependency: transitive + description: + name: test + url: "https://pub.dartlang.org" + source: hosted + version: "1.21.4" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.12" + test_core: + dependency: transitive + description: + name: test_core + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.16" + timing: + dependency: transitive + description: + name: timing + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" + universal_io: + dependency: transitive + description: + name: universal_io + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.5" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.19" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.17" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.13" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + uuid: + dependency: "direct main" + description: + name: uuid + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.6" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" + vm_service: + dependency: transitive + description: + name: vm_service + url: "https://pub.dartlang.org" + source: hosted + version: "8.3.0" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + win32: + dependency: transitive + description: + name: win32 + url: "https://pub.dartlang.org" + source: hosted + version: "2.6.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0+2" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.0" + yaml: + dependency: transitive + description: + name: yaml + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.1" +sdks: + dart: ">=2.18.0 <3.0.0" + flutter: ">=3.3.0-0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 000000000..5c56c9c13 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,116 @@ +name: ente_auth +description: ente two-factor authenticator +version: 1.0.0+1 +publish_to: none + +environment: + sdk: ">=2.12.0 <3.0.0" + +dependencies: + adaptive_theme: ^3.1.0 # done + bloc: ^8.0.3 #done + bip39: ^1.0.6 #done + collection: # dart + computer: ^2.0.0 + connectivity: ^3.0.3 + cupertino_icons: ^1.0.0 + device_info: ^2.0.2 + email_validator: ^2.0.1 + event_bus: ^2.0.0 + dio: ^4.0.6 + expandable: ^5.0.1 + expansion_tile_card: ^2.0.0 + fk_user_agent: ^2.1.0 + flutter_email_sender: ^5.1.0 + flutter_inappwebview: ^5.7.1 + flutter_launcher_icons: ^0.9.3 + dotted_border: ^2.0.0+2 + in_app_purchase: ^0.5.2 + flutter_secure_storage: ^6.0.0 + flutter_animation_progress_bar: ^2.2.1 + flutter_slidable: ^2.0.0 + + flutter: + sdk: flutter + flutter_bloc: ^8.0.1 + flutter_native_splash: ^2.2.12 + local_auth: ^1.1.5 + pinput: ^1.2.2 + password_strength: ^0.2.0 + flutter_sodium: ^0.2.0 + flutter_windowmanager: ^0.2.0 + flutter_localizations: + sdk: flutter + # sentry: + # path: thirdparty/sentry-dart/dart + # sentry_flutter: + # path: thirdparty/sentry-dart/flutter + json_annotation: ^4.5.0 + fluttertoast: ^8.0.6 + google_nav_bar: ^5.0.5 #supported + http: ^0.13.4 + move_to_background: ^1.0.2 + otp: ^3.1.1 + path_provider: ^2.0.11 + intl: ^0.17.0 + qr_code_scanner: ^1.0.1 + sqflite: ^2.1.0 + share_plus: ^4.4.0 + package_info_plus: ^1.0.1 + shared_preferences: ^2.0.5 + flutter_easyloading: ^3.0.5 + uuid: ^3.0.4 + url_launcher: ^6.1.5 + logging: ^1.0.1 + step_progress_indicator: ^1.0.2 + confetti: ^0.7.0 + clipboard: ^0.1.3 + flutter_speed_dial: ^6.2.0 + +dev_dependencies: + bloc_test: ^9.0.3 + build_runner: ^2.1.11 + flutter_test: + sdk: flutter + json_serializable: ^6.2.0 + lints: ^1.0.1 + mocktail: ^0.3.0 + + +# The following section is specific to Flutter. +flutter: + uses-material-design: true + generate: true + + # https://docs:flutter:dev/development/ui/assets-and-images: + assets: + - assets/ + + fonts: + - family: Inter + fonts: + - asset: fonts/Inter-Regular.ttf + - asset: fonts/Inter-Medium.ttf + - asset: fonts/Inter-Light.ttf + - asset: fonts/Inter-SemiBold.ttf + - asset: fonts/Inter-Bold.ttf + - family: Montserrat + fonts: + - asset: fonts/Montserrat-Bold.ttf + +flutter_icons: + android: "launcher_icon" + adaptive_icon_foreground: "assets/icon-light-adaptive-fg.png" + adaptive_icon_background: "#ffffff" + ios: true + image_path: "assets/icon-light.png" + remove_alpha_ios: true + +flutter_native_splash: + color: "#ffffff" + color_dark: "#000000" + image: assets/splash-screen-light.png + image_dark: assets/splash-screen-dark.png + android_fullscreen: true + android_gravity: center + ios_content_mode: center diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart new file mode 100644 index 000000000..89eda5bb6 --- /dev/null +++ b/test/app/view/app_test.dart @@ -0,0 +1,11 @@ +import "package:ente_auth/app/app.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("App", () { + testWidgets("renders CounterPage", (tester) async { + await tester.pumpWidget(const App()); + // expect(find.byType(CounterPage), findsOneWidget); + }); + }); +} diff --git a/test/helpers/helpers.dart b/test/helpers/helpers.dart new file mode 100644 index 000000000..eae3ef75d --- /dev/null +++ b/test/helpers/helpers.dart @@ -0,0 +1 @@ +export "pump_app.dart"; diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart new file mode 100644 index 000000000..f77629c85 --- /dev/null +++ b/test/helpers/pump_app.dart @@ -0,0 +1,19 @@ +import "package:ente_auth/l10n/l10n.dart"; +import "package:flutter/material.dart"; +import "package:flutter_localizations/flutter_localizations.dart"; +import "package:flutter_test/flutter_test.dart"; + +extension PumpApp on WidgetTester { + Future pumpApp(Widget widget) { + return pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: widget, + ), + ); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 000000000..837b36fef --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,28 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import "package:ente_auth/app/view/app.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + testWidgets("Counter increments smoke test", (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const App()); + + // Verify that our counter starts at 0. + expect(find.text("Get Started"), findsOneWidget); + expect(find.text("1"), findsNothing); + + // // Tap the "+" icon and trigger a frame. + // await tester.tap(find.byIcon(Icons.add)); + // await tester.pump(); + // + // // Verify that our counter has incremented. + // expect(find.text("0"), findsNothing); + // expect(find.text("1"), findsOneWidget); + }); +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 000000000..66a69cb18 Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 000000000..69c31fc5d Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 000000000..d920815d3 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/favicon.png b/web/icons/favicon.png new file mode 100644 index 000000000..66a69cb18 Binary files /dev/null and b/web/icons/favicon.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 000000000..998d81d85 --- /dev/null +++ b/web/index.html @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + My App + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 000000000..232359953 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "My App", + "short_name": "My App", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "ente two-factor authenticator", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/web/splash/img/dark-1x.png b/web/splash/img/dark-1x.png new file mode 100644 index 000000000..87f84c70e Binary files /dev/null and b/web/splash/img/dark-1x.png differ diff --git a/web/splash/img/dark-2x.png b/web/splash/img/dark-2x.png new file mode 100644 index 000000000..ce01bec05 Binary files /dev/null and b/web/splash/img/dark-2x.png differ diff --git a/web/splash/img/dark-3x.png b/web/splash/img/dark-3x.png new file mode 100644 index 000000000..75f4b1f3c Binary files /dev/null and b/web/splash/img/dark-3x.png differ diff --git a/web/splash/img/dark-4x.png b/web/splash/img/dark-4x.png new file mode 100644 index 000000000..2beb1c816 Binary files /dev/null and b/web/splash/img/dark-4x.png differ diff --git a/web/splash/img/light-1x.png b/web/splash/img/light-1x.png new file mode 100644 index 000000000..899cecf22 Binary files /dev/null and b/web/splash/img/light-1x.png differ diff --git a/web/splash/img/light-2x.png b/web/splash/img/light-2x.png new file mode 100644 index 000000000..4bb7a5751 Binary files /dev/null and b/web/splash/img/light-2x.png differ diff --git a/web/splash/img/light-3x.png b/web/splash/img/light-3x.png new file mode 100644 index 000000000..176f0c723 Binary files /dev/null and b/web/splash/img/light-3x.png differ diff --git a/web/splash/img/light-4x.png b/web/splash/img/light-4x.png new file mode 100644 index 000000000..a0d1a26f7 Binary files /dev/null and b/web/splash/img/light-4x.png differ diff --git a/web/splash/splash.js b/web/splash/splash.js new file mode 100644 index 000000000..3b6ed11f3 --- /dev/null +++ b/web/splash/splash.js @@ -0,0 +1,5 @@ +function removeSplashFromWeb() { + document.getElementById("splash")?.remove(); + document.getElementById("splash-branding")?.remove(); + document.body.style.background = "transparent"; +} diff --git a/web/splash/style.css b/web/splash/style.css new file mode 100644 index 000000000..ed16b600b --- /dev/null +++ b/web/splash/style.css @@ -0,0 +1,63 @@ +body { + margin:0; + height:100%; + background: #ffffff; + + background-size: 100% 100%; +} + +.center { + margin: 0; + position: absolute; + top: 50%; + left: 50%; + -ms-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +.contain { + display:block; + width:100%; height:100%; + object-fit: contain; +} + +.stretch { + display:block; + width:100%; height:100%; +} + +.cover { + display:block; + width:100%; height:100%; + object-fit: cover; +} + +.bottom { + position: absolute; + bottom: 0; + left: 50%; + -ms-transform: translate(-50%, 0); + transform: translate(-50%, 0); +} + +.bottomLeft { + position: absolute; + bottom: 0; + left: 0; +} + +.bottomRight { + position: absolute; + bottom: 0; + right: 0; +} + +@media (prefers-color-scheme: dark) { + body { + margin:0; + height:100%; + background: #000000; + + background-size: 100% 100%; + } +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 000000000..d492d0d98 --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 000000000..d481d1864 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,95 @@ +cmake_minimum_required(VERSION 3.14) +project(ente_auth LANGUAGES CXX) + +set(BINARY_NAME "ente_auth") + +cmake_policy(SET CMP0063 NEW) + +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Configure build options. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() + +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation ui.settings that should be applied to most targets. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") + +# Flutter library and tool build rules. +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build +add_subdirectory("runner") + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 000000000..b2e4bd8d6 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,103 @@ +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + windows-x64 $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..2048c4552 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,17 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..dc139d85a --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 000000000..de626cc89 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_windows + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 000000000..de2d8916b --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,17 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) +apply_standard_settings(${BINARY_NAME}) +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 000000000..23e08b7a7 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#ifdef FLUTTER_BUILD_NUMBER +#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER +#else +#define VERSION_AS_NUMBER 1,0,0 +#endif + +#ifdef FLUTTER_BUILD_NAME +#define VERSION_AS_STRING #FLUTTER_BUILD_NAME +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "io.ente.authenticator.ente-auth" "\0" + VALUE "FileDescription", "ente Authenticator" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "ente Authenticator" "\0" + VALUE "LegalCopyright", "Copyright (C) 2022 io.ente.authenticator. All rights reserved." "\0" + VALUE "OriginalFilename", "ente_authenticator.exe" "\0" + VALUE "ProductName", "ente Authenticator" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 000000000..b43b9095e --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,61 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 000000000..6da0652f0 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 000000000..df82ae0a5 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.CreateAndShow(L"My App", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 000000000..d7b448fc5 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +// +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 000000000..c04e20caf Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 000000000..c977c4a42 --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 000000000..d19bdbbcc --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,64 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr); + if (target_length == 0) { + return std::string(); + } + std::string utf8_string; + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, utf8_string.data(), + target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 000000000..3879d5475 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 000000000..c10f08dc7 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,245 @@ +#include "win32_window.h" + +#include + +#include "resource.h" + +namespace { + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + FreeLibrary(user32_module); + } +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + return OnCreate(); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 000000000..17ba43112 --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,98 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates and shows a win32 window with |title| and position and size using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size to will treat the width height passed in to this function + // as logical pixels and scale to appropriate for the default monitor. Returns + // true if the window was created successfully. + bool CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responsponds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_