Compare commits
23 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa74e12ee7 | ||
|
|
7d2a147fec | ||
|
|
996e879509 | ||
|
|
3ffaf34ca4 | ||
|
|
503c448983 | ||
|
|
ef13b31dee | ||
|
|
b2880e6546 | ||
| 04d52bffaa | |||
|
|
fa0809f021 | ||
|
|
f7452832ea | ||
|
|
d7179694a6 | ||
|
|
aa8c7ec71a | ||
|
|
6f6fa20b58 | ||
|
|
d6f4c434fa | ||
|
|
52947e153d | ||
|
|
c693e6fcb9 | ||
|
|
eaaa3f2ede | ||
|
|
7d3c8b423c | ||
|
|
f80315e7c4 | ||
|
|
f57336b7ea | ||
|
|
854f9ab31d | ||
|
|
0ba82370f1 | ||
|
|
e94e4158cc |
11
.gitignore
vendored
|
|
@ -42,3 +42,14 @@ app.*.map.json
|
|||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
/android/build
|
||||
|
||||
# Rust wrapper crate (native/)
|
||||
native/target/
|
||||
|
||||
# Rust cdylibs built into the APK by devbox run build-native-android
|
||||
android/app/src/main/jniLibs/
|
||||
|
||||
# Patrol generates the bundle from the integration tests at run time
|
||||
**/test_bundle.dart
|
||||
.patrol.env
|
||||
|
|
|
|||
29
.metadata
|
|
@ -1,11 +1,11 @@
|
|||
# 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.
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2
|
||||
channel: stable
|
||||
revision: "nixpkgs000000000000000000000000000000000"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
|
|
@ -13,26 +13,11 @@ project_type: app
|
|||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2
|
||||
base_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2
|
||||
- platform: android
|
||||
create_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2
|
||||
base_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2
|
||||
- platform: ios
|
||||
create_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2
|
||||
base_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2
|
||||
create_revision: nixpkgs000000000000000000000000000000000
|
||||
base_revision: nixpkgs000000000000000000000000000000000
|
||||
- platform: linux
|
||||
create_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2
|
||||
base_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2
|
||||
- platform: macos
|
||||
create_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2
|
||||
base_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2
|
||||
- platform: web
|
||||
create_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2
|
||||
base_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2
|
||||
- platform: windows
|
||||
create_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2
|
||||
base_revision: d3d8effc686d73e0114d71abdcccef63fa1f25d2
|
||||
create_revision: nixpkgs000000000000000000000000000000000
|
||||
base_revision: nixpkgs000000000000000000000000000000000
|
||||
|
||||
# User provided section
|
||||
|
||||
|
|
|
|||
17
AI-DECLARATION.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
version: "0.1.2"
|
||||
level: copilot
|
||||
processes:
|
||||
design: assist
|
||||
implementation: pair
|
||||
testing: pair
|
||||
documentation: copilot
|
||||
review: copilot
|
||||
deployment: assist
|
||||
---
|
||||
|
||||
This format is based on [AI-DECLARATION.md](https://ai-declaration.md/en/0.1.2).
|
||||
|
||||
## Notes
|
||||
|
||||
-
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 Sarthak Kimtani
|
||||
Copyright (c) 2026 randogoth
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
|||
97
README.md
|
|
@ -1,80 +1,45 @@
|
|||
# FlashMail
|
||||
# キラキラ kirakira
|
||||
|
||||
FlashMail is a Mobile application designed to provide users with a temporary email solution powered by the mail.tm platform. With FlashMail, users can receive and view emails conveniently while maintaining their privacy. The app utilizes Riverpod as its state management solution for efficient and organized data handling.
|
||||
[](https://ai-declaration.md)
|
||||
[](https://opensource.org/licenses/MIT)   [](https://github.com/sarthakkimtani/flash-mail)
|
||||
|
||||
This repository contains the source code for FlashMail developed using the Flutter framework. The app is designed to provide various features and functionalities to enhance user experience. Below, you will find information about how to download the app, its features, dependencies, license, and more.
|
||||
A mobile and desktop client for [smolmail](https://smol.place), a minimalist end-to-end encrypted mail protocol: one Ed25519 identity, five operations, sealed and signed messages over a Noise_NX transport.
|
||||
|
||||
## Download
|
||||
kirakira began as [FlashMail](https://github.com/sarthakkimtani/flash-mail), a Flutter UI template for a disposable-email app built on mail.tm. Its protocol core is now [fumi-core](https://code.randogoth.com/randogoth/fumi), the Rust library the reference CLI is built on: `native/` wraps it into a C ABI and ships it in the APK as `libsmol_mail_native.so` (SQLite bundled), `lib/native/` speaks that ABI over dart:ffi, and `lib/smol/` is the thin facade the UI reads. The UI is Material 3 — dark navy and mint green by default, with a matching light theme — built from a single `ColorScheme` and `AppColors` extension rather than hardcoded colors per screen.
|
||||
|
||||
You can download the latest version of the app from the [GitHub Releases](https://github.com/sarthakkimtani/flash-mail/releases) page. Look for the most recent release and download the corresponding APK or installer file for your device.
|
||||
## What it does
|
||||
|
||||
## Requirements
|
||||
- Create or restore an identity from a 32-byte seed — that's the only secret.
|
||||
- Pin a server's key, register or recall an address, then fetch and send sealed mail.
|
||||
- Trust-on-first-use for server and contact keys, with rotation support and on-screen warnings for anything unverified.
|
||||
- Export/import inbox, sent mail, contacts and server pins as one shareable backup file — never the seed.
|
||||
- Share your address as a `smol://` QR code, and react to any `smol://` link a scanner or browser hands the device: its key lands in the confirm-first import dialog.
|
||||
|
||||
- Any Operating System (ie. MacOS X, Linux, Windows)
|
||||
- Any IDE with Flutter SDK installed (ie. IntelliJ, Android Studio, VSCode, etc)
|
||||
- A little knowledge of Dart and Flutter
|
||||
Mail, contacts and pinned server keys live in fumi-core's SQLite store, bundled into the native library; the seed, read marks and settings stay app-side in Hive.
|
||||
|
||||
## Features
|
||||
## Layout
|
||||
|
||||
- Instantly create temporary email addresses to receive messages without revealing your personal email.
|
||||
- Enjoy a seamless user experience with an intuitive and user-friendly interface, making it easy to navigate through your emails effortlessly.
|
||||
- Access and download attachments with ease, allowing you to view and save important files directly from your emails.
|
||||
- Access and read your emails effortlessly with a seamless viewing experience.
|
||||
- Benefit from a compact APK size of just 22 MB, ensuring that FlashMail takes up minimal storage space on your device.
|
||||
```
|
||||
lib/native/ dart:ffi binding: handles, error codes, isolate plumbing
|
||||
lib/smol/ thin facades over the binding: address, store, client
|
||||
lib/presentation/ screens, widgets and theme (Riverpod + auto_route)
|
||||
lib/data/ Riverpod providers
|
||||
native/ Rust wrapper crate: the C ABI over fumi-core, pinned by rev
|
||||
integration_test/ Patrol device tests against a live server
|
||||
test/ store, recall, binding, interop and e2e tests
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
## Run and verify
|
||||
|
||||
<img src="screenshots/1.png" height="300em" /> <img src="screenshots/2.png" height="300em" /> <img src="screenshots/3.png" height="300em" />
|
||||
<img src="screenshots/4.png" height="300em" />
|
||||
```
|
||||
devbox run build-native-android # the .so the APK loads — once per Rust change
|
||||
devbox run analyze
|
||||
devbox run test
|
||||
flutter run --flavor prod
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
To run the app locally and make modifications, follow these steps:
|
||||
|
||||
1. Ensure you have Flutter SDK installed on your machine. You can download it from the official Flutter website: https://flutter.dev.
|
||||
2. Clone this repository to your local machine using the following command:<br>
|
||||
```bash
|
||||
git clone https://github.com/sarthakkimtani/flash-mail.git
|
||||
```
|
||||
3. Navigate to the project directory:<br><br>
|
||||
```bash
|
||||
cd flash-mail
|
||||
```
|
||||
4. Fetch the app's dependencies by running the following command:<br>
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
5. Connect your device or start an emulator.
|
||||
6. Run the app using the following command:<br>
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Name | Usage |
|
||||
| ------------------------------------------------------------------------- | --------------------------- |
|
||||
| [**Auto Route**](https://pub.dev/packages/auto_route) | Navigation & Routing |
|
||||
| [**Dio**](https://pub.dev/packages/dio) | HTTP Requests |
|
||||
| [**External Path**](https://pub.dev/packages/external_path) | External Storage Path |
|
||||
| [**Flash**](https://pub.dev/packages/flash) | Alerts & Dialogs |
|
||||
| [**Flutter Downloader**](https://pub.dev/packages/flutter_downloader) | Download Files |
|
||||
| [**Flutter InAppWebView**](https://pub.dev/packages/flutter_inappwebview) | In-App WebView |
|
||||
| [**Flutter Riverpod**](https://pub.dev/packages/flutter_riverpod) | Global State Management |
|
||||
| [**Freezed**](https://pub.dev/packages/freezed) | Code Generation |
|
||||
| [**Hive**](https://pub.dev/packages/hive) | Local Database |
|
||||
| [**Intl**](https://pub.dev/packages/intl) | Internationalization |
|
||||
| [**Shimmer**](https://pub.dev/packages/shimmer) | Shimmer for Loading Screens |
|
||||
| [**URL Launcher**](https://pub.dev/packages/url_launcher) | URL Launcher |
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions to this app are welcome! If you find any issues or have ideas for improvements, please open an issue or submit a pull request. Make sure to follow the repository's guidelines for contributing.
|
||||
`test/e2e_test.dart` runs a live round-trip against the local bunshin on `127.0.0.1:1961` and skips itself when nothing's listening there. On-device flows are covered by the Patrol suite — `devbox run test-integration` builds the `integration` flavor (installed beside the real app, its data cleared between tests) and drives onboarding, registration, fetch cancellation, rotation and the unchanged-key refresh against the same server. To exercise the smol:// deep link by hand, scan `test-qr.png` — it encodes the `kirakira@localhost` identity — with any device camera and confirm the import dialog.
|
||||
|
||||
## License
|
||||
|
||||
This app is distributed under the [MIT License](https://github.com/sarthakkimtani/flash-mail/blob/main/LICENSE). Feel free to modify and use it as per your requirements.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
FlashMail is an independent project and is not affiliated with or endorsed by the mail.tm platform. Please review and comply with the terms of service of mail.tm when using this application.
|
||||
MIT — see LICENSE.md.
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ include: package:flutter_lints/flutter.yaml
|
|||
|
||||
analyzer:
|
||||
exclude:
|
||||
- lib/data/models/*/*.g.dart
|
||||
- lib/data/models/*/*.freezed.dart
|
||||
- lib/presentation/routes/app_router.gr.dart
|
||||
|
||||
linter:
|
||||
rules:
|
||||
|
|
|
|||
5
android/.gitignore
vendored
|
|
@ -1,8 +1,9 @@
|
|||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
# gradlew stays tracked: it carries the nix project-cache redirect that every
|
||||
# non-flutter gradle invocation (patrol_cli, manual builds) needs, so the
|
||||
# wrapper can't be re-injected from the template.
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
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'
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||
|
||||
def keystoreProperties = new Properties()
|
||||
def keystorePropertiesFile = rootProject.file('key.properties')
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.example.flash_mail"
|
||||
compileSdkVersion 34
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.app.flash_mail"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdkVersion flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
storeFile file(keystoreProperties['storeFile'])
|
||||
storePassword keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
}
|
||||
73
android/app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.smol_mail"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = "27.0.12077973"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.app.smol_mail"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
// The Android Test Orchestrator (with clearPackageData) wipes the
|
||||
// integration flavor's app data between test runs, and the flavor
|
||||
// installs it beside — never over — the real app, so every patrol run
|
||||
// starts at onboarding with a fresh identity.
|
||||
testInstrumentationRunner = "pl.leancode.patrol.PatrolJUnitRunner"
|
||||
testInstrumentationRunnerArguments["clearPackageData"] = "true"
|
||||
}
|
||||
|
||||
testOptions {
|
||||
execution = "ANDROIDX_TEST_ORCHESTRATOR"
|
||||
}
|
||||
|
||||
flavorDimensions += "test"
|
||||
productFlavors {
|
||||
// The real app: the identity the alpha testers hold, no suffix.
|
||||
// With a flavor dimension every build must name its flavor, so
|
||||
// shipping builds are `flutter build apk --flavor prod`.
|
||||
create("prod") {
|
||||
dimension = "test"
|
||||
}
|
||||
// The patrol test app, installed beside — never over — the real one.
|
||||
create("integration") {
|
||||
dimension = "test"
|
||||
applicationIdSuffix = ".integration"
|
||||
versionNameSuffix = "-integration"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The orchestrator referenced by testOptions.execution above. The patrol
|
||||
// native library itself comes from the patrol pub package's android
|
||||
// module (a plugin subproject), not from a Maven artifact.
|
||||
androidTestUtil("androidx.test:orchestrator:1.5.1")
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.app.smol_mail.integration;
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.junit.runners.Parameterized;
|
||||
import org.junit.runners.Parameterized.Parameters;
|
||||
import pl.leancode.patrol.PatrolJUnitRunner;
|
||||
|
||||
// The Patrol host class: each Dart test in the integration_test directory is
|
||||
// executed through this JUnit suite. Its package is the flavored applicationId
|
||||
// (com.app.smol_mail + .integration suffix); the namespace differs, so
|
||||
// MainActivity needs the explicit import below.
|
||||
@RunWith(Parameterized.class)
|
||||
public class MainActivityTest {
|
||||
|
||||
@Parameters(name = "{0}")
|
||||
public static Object[] testCases() {
|
||||
PatrolJUnitRunner instrumentation =
|
||||
(PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation();
|
||||
instrumentation.setUp(com.example.smol_mail.MainActivity.class);
|
||||
instrumentation.waitForPatrolAppService();
|
||||
return instrumentation.listDartTests();
|
||||
}
|
||||
|
||||
public MainActivityTest(String dartTestName) {
|
||||
this.dartTestName = dartTestName;
|
||||
}
|
||||
|
||||
private final String dartTestName;
|
||||
|
||||
@Test
|
||||
public void runDartTest() {
|
||||
PatrolJUnitRunner instrumentation =
|
||||
(PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation();
|
||||
instrumentation.runDartTest(dartTestName);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="FlashMail"
|
||||
android:label="kirakira"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/launcher_icon">
|
||||
<activity
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
|
|
@ -20,37 +19,19 @@
|
|||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- smol:// (SPEC.md §3): the self-certifying form a QR scanner
|
||||
or a link hands over; the app imports the key it carries
|
||||
after the user confirms. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="smol"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<provider
|
||||
android:name="vn.hunghd.flutterdownloader.DownloadedFileProvider"
|
||||
android:authorities="${applicationId}.flutter_downloader.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths"/>
|
||||
</provider>
|
||||
</application>
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="https" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.DIAL" />
|
||||
<data android:scheme="tel" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package com.example.flash_mail
|
||||
package com.example.smol_mail
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
BIN
android/app/src/main/res/drawable-hdpi/branding.png
Normal file
|
After Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 5 KiB |
BIN
android/app/src/main/res/drawable-mdpi/branding.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 69 B |
|
|
@ -6,4 +6,7 @@
|
|||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
<item android:bottom="0dp">
|
||||
<bitmap android:gravity="bottom" android:src="@drawable/branding"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
|
|
|||
BIN
android/app/src/main/res/drawable-xhdpi/branding.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 6.5 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/branding.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 9.6 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/branding.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 69 B |
|
|
@ -6,4 +6,7 @@
|
|||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
<item android:bottom="0dp">
|
||||
<bitmap android:gravity="bottom" android:src="@drawable/branding"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1,007 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 6 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 7.9 KiB |
|
|
@ -2,7 +2,6 @@
|
|||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowSplashScreenBackground">@color/ic_launcher_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowSplashScreenBackground">@color/ic_launcher_background</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
buildscript {
|
||||
ext.kotlin_version = '1.7.10'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.3.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.buildDir = '../build'
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
33
android/build.gradle.kts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
// NDK 28 (flutter.ndkVersion default) is not installed; override all library
|
||||
// modules to use the installed NDK 27.
|
||||
subprojects {
|
||||
afterEvaluate {
|
||||
if (plugins.hasPlugin("com.android.library")) {
|
||||
configure<com.android.build.gradle.LibraryExtension> {
|
||||
ndkVersion = "27.0.12077973"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
org.gradle.jvmargs=-Xmx1536M
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||
|
|
|
|||
171
android/gradlew
vendored
Executable file
|
|
@ -0,0 +1,171 @@
|
|||
#!/nix/store/gik3rh1vz2jlgnifb9dh6vc6sxwwz9jj-bash-5.3p9/bin/bash
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn ( ) {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die ( ) {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
|
||||
function splitJvmOpts() {
|
||||
JVM_OPTS=("$@")
|
||||
}
|
||||
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
|
||||
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
|
||||
|
||||
# Nix's flutter keeps the flutter_tools/gradle composite build on a read-only
|
||||
# store path, so every gradle invocation needs its project and Kotlin caches
|
||||
# redirected off it — the same two arguments the flutter tool itself passes.
|
||||
# Without them, any gradlew run other than through the flutter tool (patrol_cli,
|
||||
# manual builds) fails with a silent exit 1.
|
||||
_nix_cache="${XDG_CACHE_HOME:-$HOME/.cache}/flutter/nix-flutter-tools-gradle/gradle-project-cache"
|
||||
case " $* " in
|
||||
*" --project-cache-dir"*) ;;
|
||||
*) set -- "$@" "--project-cache-dir=$_nix_cache" "-Pkotlin.project.persistent.dir=$_nix_cache/kotlin" ;;
|
||||
esac
|
||||
|
||||
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
90
android/gradlew.bat
vendored
Executable file
|
|
@ -0,0 +1,90 @@
|
|||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windowz variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
if "%@eval[2+2]" == "4" goto 4NT_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
goto execute
|
||||
|
||||
:4NT_args
|
||||
@rem Get arguments from the 4NT Shell from JP Software
|
||||
set CMD_LINE_ARGS=%$
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
136
android/nix-android-sdk/flake.lock
generated
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
{
|
||||
"nodes": {
|
||||
"android-nixpkgs": {
|
||||
"inputs": {
|
||||
"devshell": "devshell",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768249546,
|
||||
"narHash": "sha256-cpNxcSNveCBSDmocYlPrayaP8CX8ik0SKR4aNbrxOqA=",
|
||||
"owner": "tadfisher",
|
||||
"repo": "android-nixpkgs",
|
||||
"rev": "001ffcb40e86888d38acf7b851f9a1bfac7d3a81",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "tadfisher",
|
||||
"ref": "stable",
|
||||
"repo": "android-nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devshell": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"android-nixpkgs",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1764011051,
|
||||
"narHash": "sha256-M7SZyPZiqZUR/EiiBJnmyUbOi5oE/03tCeFrTiUZchI=",
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"rev": "17ed8d9744ebe70424659b0ef74ad6d41fc87071",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1768127708,
|
||||
"narHash": "sha256-1Sm77VfZh3mU0F5OqKABNLWxOuDeHIlcFjsXeeiPazs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ffbc9f8cbaacfb331b6017d5a5abb21a492c9a38",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"android-nixpkgs": "android-nixpkgs",
|
||||
"flake-utils": "flake-utils_2"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
27
android/nix-android-sdk/flake.nix
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
description = "A Flake for the Android SDK";
|
||||
|
||||
inputs = {
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
android-nixpkgs.url = "github:tadfisher/android-nixpkgs/stable";
|
||||
};
|
||||
|
||||
outputs = { self, flake-utils, android-nixpkgs }:
|
||||
flake-utils.lib.eachSystem ["x86_64-linux" "x86_64-darwin" "aarch64-darwin"] (system: {
|
||||
packages.android-sdk = android-nixpkgs.sdk."${system}" (sdkPkgs: with sdkPkgs; [
|
||||
cmdline-tools-latest
|
||||
build-tools-33-0-0
|
||||
build-tools-34-0-0
|
||||
build-tools-35-0-0
|
||||
build-tools-36-1-0
|
||||
cmake-3-22-1
|
||||
platform-tools
|
||||
platforms-android-33
|
||||
platforms-android-34
|
||||
platforms-android-35
|
||||
platforms-android-36
|
||||
emulator
|
||||
ndk-27-0-12077973
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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"
|
||||
25
android/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
pluginManagement {
|
||||
val flutterSdkPath = run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.9.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
BIN
assets/images/bug_logo.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
assets/images/sparkle.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
39
devbox.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/jetify-com/devbox/main/packages/devbox.schema.json",
|
||||
"packages": [
|
||||
"flutter@3.41.9-sdk-links",
|
||||
"nixpkgs#android-tools",
|
||||
"nixpkgs#openjdk17",
|
||||
"path:./android/nix-android-sdk#android-sdk",
|
||||
"nixpkgs#clang",
|
||||
"nixpkgs#cmake",
|
||||
"nixpkgs#ninja",
|
||||
"nixpkgs#pkg-config",
|
||||
"nixpkgs#gtk3",
|
||||
"nixpkgs#libsysprof-capture",
|
||||
"nixpkgs#rustup",
|
||||
"nixpkgs#cargo-ndk"
|
||||
],
|
||||
"shell": {
|
||||
"init_hook": [
|
||||
"flutter config --jdk-dir \"$(dirname \"$(dirname \"$(command -v java)\")\")\" > /dev/null",
|
||||
"flutter --version"
|
||||
],
|
||||
"scripts": {
|
||||
"analyze": "flutter analyze",
|
||||
"test": "flutter test",
|
||||
"run-linux": "flutter run -d linux",
|
||||
"build-linux": "flutter build linux",
|
||||
"link-android-tools": "if [ -n \"$ANDROID_HOME\" ] && [ ! -d \"$ANDROID_HOME/cmdline-tools/latest\" ] && [ -d \"$ANDROID_HOME/cmdline-tools/8.0\" ]; then ln -s \"$ANDROID_HOME/cmdline-tools/8.0\" \"$ANDROID_HOME/cmdline-tools/latest\"; fi",
|
||||
"build-native": "cd native && cargo build --release",
|
||||
"build-native-android": "rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android && cd native && cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -o ../android/app/src/main/jniLibs build --release",
|
||||
"test-native": "cd native && cargo test --release && cargo test --release -- --ignored",
|
||||
"activate-patrol": "dart pub global activate patrol_cli",
|
||||
"test-integration": "export PATH=\"$HOME/.pub-cache/bin:$PATH\" && patrol test --dart-define=SMOL_TEST_HOST=$(hostname -I | tr ' ' '\\n' | grep -E '^(192\\.168|10\\.|172\\.)' | head -1)"
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"ANDROID_SDK_ROOT": "$DEVBOX_PACKAGES_DIR/share/android-sdk",
|
||||
"ANDROID_HOME": "$DEVBOX_PACKAGES_DIR/share/android-sdk"
|
||||
}
|
||||
}
|
||||
97
devbox.lock
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
{
|
||||
"lockfile_version": "1",
|
||||
"packages": {
|
||||
"flutter@3.41.9-sdk-links": {
|
||||
"last_modified": "2026-05-21T08:15:18Z",
|
||||
"resolved": "github:NixOS/nixpkgs/4a29d733e8a7d5b824c3d8c958a946a9867b3eb2#flutter",
|
||||
"source": "devbox-search",
|
||||
"version": "3.41.9-sdk-links",
|
||||
"systems": {
|
||||
"aarch64-darwin": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/mp9x3mn0x5hs11m8z4m20w8gndkkccb7-flutter-wrapped-3.41.9-sdk-links",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/mp9x3mn0x5hs11m8z4m20w8gndkkccb7-flutter-wrapped-3.41.9-sdk-links"
|
||||
},
|
||||
"aarch64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/aidhap5ilmmf4fzyr0gwxxls0d7b9h7z-flutter-wrapped-3.41.9-sdk-links",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/aidhap5ilmmf4fzyr0gwxxls0d7b9h7z-flutter-wrapped-3.41.9-sdk-links"
|
||||
},
|
||||
"x86_64-darwin": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/27gbcgwqbaaf81i792zssg862xcwzwzg-flutter-wrapped-3.41.9-sdk-links",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/27gbcgwqbaaf81i792zssg862xcwzwzg-flutter-wrapped-3.41.9-sdk-links"
|
||||
},
|
||||
"x86_64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/fqcjikcpdcn123csd805bmg05jv75szj-flutter-wrapped-3.41.9-sdk-links",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/fqcjikcpdcn123csd805bmg05jv75szj-flutter-wrapped-3.41.9-sdk-links"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github:NixOS/nixpkgs/nixpkgs-unstable": {
|
||||
"last_modified": "2026-08-27T07:16:00Z",
|
||||
"resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410?lastModified=1787814960&narHash=sha256-PYZq1qzCJXC2zGI0mH07vrZBsw6DRBAOX0jN1pPtqOQ%3D"
|
||||
},
|
||||
"nixpkgs#android-tools": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#android-tools"
|
||||
},
|
||||
"nixpkgs#cargo-ndk": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#cargo-ndk"
|
||||
},
|
||||
"nixpkgs#clang": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#clang"
|
||||
},
|
||||
"nixpkgs#cmake": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#cmake"
|
||||
},
|
||||
"nixpkgs#gtk3": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#gtk3"
|
||||
},
|
||||
"nixpkgs#libsysprof-capture": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#libsysprof-capture"
|
||||
},
|
||||
"nixpkgs#ninja": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#ninja"
|
||||
},
|
||||
"nixpkgs#openjdk17": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#openjdk17"
|
||||
},
|
||||
"nixpkgs#pkg-config": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#pkg-config"
|
||||
},
|
||||
"nixpkgs#rustup": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#rustup"
|
||||
}
|
||||
}
|
||||
}
|
||||
247
integration_test/smoke_test.dart
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
// Device integration tests over the real UI, driven by Patrol against the
|
||||
// system bunshin on the test host. The integration flavor installs beside
|
||||
// the real app and the instrumentation runner clears its data, so every run
|
||||
// starts at onboarding with a fresh identity — the app's own flows are the
|
||||
// subject under test, including the network path.
|
||||
//
|
||||
// Run: devbox run test-integration
|
||||
// (the script resolves the host's LAN IP into SMOL_TEST_HOST, because the
|
||||
// device reaches the server over Wi-Fi, not loopback.)
|
||||
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:flutter_test/flutter_test.dart";
|
||||
import "package:hive_flutter/hive_flutter.dart";
|
||||
import "package:path_provider/path_provider.dart";
|
||||
|
||||
import "package:patrol/patrol.dart";
|
||||
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/app_widget.dart";
|
||||
import "package:smol_mail/smol/config.dart";
|
||||
import "package:smol_mail/smol/store.dart";
|
||||
|
||||
const testHost = String.fromEnvironment("SMOL_TEST_HOST", defaultValue: "10.0.2.2");
|
||||
|
||||
// The system bunshin's static key: a documented, operator-supplied value on
|
||||
// this machine — the trusted channel SPEC.md §4 asks a pin to come from.
|
||||
const serverKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq";
|
||||
|
||||
// main()'s boot, minus runApp: the test pumps the same tree. The container is
|
||||
// the one the UI reads from, so the tests drive the same SmolClient the
|
||||
// screens do — the fetch cancellation test needs exactly that.
|
||||
Future<ProviderContainer> boot(PatrolIntegrationTester $) async {
|
||||
final dataDir = await getApplicationSupportDirectory();
|
||||
Hive.init(dataDir.path);
|
||||
final store = await SmolStore.open(dbPath: "${dataDir.path}/smol-mail.db");
|
||||
applyPresetServer(store);
|
||||
final container =
|
||||
ProviderContainer(overrides: [storeProvider.overrideWithValue(store)]);
|
||||
await $.pumpWidgetAndSettle(UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const AppWidget(),
|
||||
));
|
||||
return container;
|
||||
}
|
||||
|
||||
// Onboarding into a registered account: a fresh identity, backed up, then
|
||||
// registered under a name no server has bound yet. Returns the username.
|
||||
Future<String> onboard(PatrolIntegrationTester $, ProviderContainer container) async {
|
||||
await $("Create Identity").tap();
|
||||
await $.pumpAndSettle();
|
||||
await $("I have backed it up").waitUntilVisible();
|
||||
await $("I have backed it up").tap();
|
||||
await $.pumpAndSettle();
|
||||
|
||||
final user = "it${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}";
|
||||
// Enter text by TextField, not by its labelText: the decoration label is
|
||||
// a RichText that is not hit-testable, so a text finder times out.
|
||||
await $.enterText($(TextField).at(0), "$user@$testHost:1961");
|
||||
await $.enterText($(TextField).at(1), serverKey);
|
||||
await $("Pin and Register").tap();
|
||||
|
||||
// The register round trip is real network: the inbox tabs only exist
|
||||
// behind HomeGuard, so reaching them proves the account bound.
|
||||
await $("Inbox").waitUntilVisible(timeout: const Duration(seconds: 30));
|
||||
return user;
|
||||
}
|
||||
|
||||
// Imports the account's own smol:// address as a contact — the §8 verified
|
||||
// path — so later steps have an entry to re-resolve. Leaves the app on the
|
||||
// Contacts screen.
|
||||
Future<void> importSelfContact(PatrolIntegrationTester $, ProviderContainer container) async {
|
||||
final client = container.read(clientProvider);
|
||||
final uri = client.accountAddress()!.uri(client.identity!.publicKey);
|
||||
await $("Contacts").tap();
|
||||
await $.pumpAndSettle();
|
||||
await $(Icons.person_add).tap();
|
||||
await $.pumpAndSettle();
|
||||
await $.enterText($(TextField).at(0), uri);
|
||||
await $("Import").tap();
|
||||
await $("contact imported (verified key)").waitUntilVisible();
|
||||
// The snackbar floats over the bottom navigation bar; let it expire
|
||||
// before the next navigation tap.
|
||||
await $.pump(const Duration(seconds: 5));
|
||||
}
|
||||
|
||||
void main() {
|
||||
patrolTest("smoke: onboarding, register, self-send, fetch, read in requests",
|
||||
($) async {
|
||||
final container = await boot($);
|
||||
final user = await onboard($, container);
|
||||
|
||||
// Compose the first self-addressed mail.
|
||||
await $(Icons.edit).tap();
|
||||
await $.pumpAndSettle();
|
||||
await $.enterText($(TextField).at(0), "$user@$testHost:1961");
|
||||
await $.enterText($(TextField).at(1), "smoke subject");
|
||||
await $.enterText($(TextField).at(2), "smoke body from patrol");
|
||||
await $("Send").tap();
|
||||
// Compose pops back to the inbox once the send round trip completes.
|
||||
await $("Inbox").waitUntilVisible(timeout: const Duration(seconds: 30));
|
||||
await $.pumpAndSettle();
|
||||
|
||||
// Fetch over the air, then read what arrived.
|
||||
await $(Icons.cloud_download).tap();
|
||||
await $.pumpAndSettle(duration: const Duration(seconds: 5));
|
||||
await $("Requests (1)").tap();
|
||||
await $.pumpAndSettle();
|
||||
|
||||
// First contact is unsolicited, so it landed in requests (sec 5.8) —
|
||||
// and the sec 5.6 sent copy must read back as plain text, not <unreadable>.
|
||||
await $("smoke subject").tap();
|
||||
await $.pumpAndSettle();
|
||||
await $("smoke body from patrol").waitUntilVisible();
|
||||
await $.native.pressBack(); // ignore: deprecated_member_use
|
||||
await $.pumpAndSettle();
|
||||
await $("Sent").tap();
|
||||
await $.pumpAndSettle();
|
||||
await $("smoke subject").tap();
|
||||
await $.pumpAndSettle();
|
||||
await $("smoke body from patrol").waitUntilVisible();
|
||||
});
|
||||
|
||||
// Checklist item 11: a fetch interrupted mid-run keeps everything that
|
||||
// already arrived, and fetching again completes the set without re-storing
|
||||
// what the first pass took. There is no cancel control in the UI yet, so
|
||||
// the test raises the facade's cancellation flag — the same call a cancel
|
||||
// button would make; when one exists, drive it from the UI instead.
|
||||
patrolTest(
|
||||
"smoke: cancelled fetch keeps what arrived and resume completes it",
|
||||
($) async {
|
||||
final container = await boot($);
|
||||
final user = await onboard($, container);
|
||||
final client = container.read(clientProvider);
|
||||
final address = "$user@$testHost:1961";
|
||||
|
||||
// 20 letters with 40 KiB bodies: the fetch has real pages to chew
|
||||
// through, so a cancel 600 ms in lands mid-run, not after it.
|
||||
for (var i = 0; i < 20; i++) {
|
||||
await client.send(address, "cancel $i", "cancel body $i\n${"x" * 40960}");
|
||||
}
|
||||
|
||||
final fetch = client.fetch();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 600));
|
||||
client.cancelFetch();
|
||||
final summary = await fetch;
|
||||
container.read(revisionProvider.notifier).bump();
|
||||
await $.pumpAndSettle();
|
||||
|
||||
// If the cancel landed mid-run, part of the page arrived and the
|
||||
// warning banner says so; if the fetch had already finished, nothing
|
||||
// was lost either way and the resume below is a no-op.
|
||||
if (summary.stored < 20) {
|
||||
await $("fetch cancelled; partial results kept").waitUntilVisible();
|
||||
}
|
||||
|
||||
// The UI's fetch resumes and completes the set. The requests badge
|
||||
// counts every unread message, so a re-stored duplicate would push
|
||||
// the count past 20.
|
||||
await $(Icons.cloud_download).tap();
|
||||
await $.pumpAndSettle(duration: const Duration(seconds: 5));
|
||||
await $("Requests (20)")
|
||||
.waitUntilVisible(timeout: const Duration(seconds: 30));
|
||||
});
|
||||
|
||||
// Checklist item 13: after rotating, a correspondent re-resolving the
|
||||
// address sees the signed-chain rotation banner — a warning, not the
|
||||
// terminal mismatch — and mail sealed to the superseded key still reads.
|
||||
patrolTest(
|
||||
"smoke: rotation re-resolves through the chain; old mail stays readable",
|
||||
($) async {
|
||||
final container = await boot($);
|
||||
final user = await onboard($, container);
|
||||
final client = container.read(clientProvider);
|
||||
final address = "$user@$testHost:1961";
|
||||
|
||||
// A letter sealed to the pre-rotation key, waiting in requests.
|
||||
await client.send(address, "rotation subject",
|
||||
"rotation body before the key change");
|
||||
await client.fetch();
|
||||
container.read(revisionProvider.notifier).bump();
|
||||
await $.pumpAndSettle();
|
||||
|
||||
// The correspondent entry, bound to the current key, must exist before
|
||||
// the rotation so re-resolving has a known key to move away from.
|
||||
await importSelfContact($, container);
|
||||
|
||||
// Rotate: the dialog pushes the next index's key with a certificate.
|
||||
await $("Settings").tap();
|
||||
await $.pumpAndSettle();
|
||||
// The rotate row sits below the settings list's fold, and a SliverList
|
||||
// builds lazily — off-screen rows do not exist as widgets until
|
||||
// scrolled to, so bring it into the tree before tapping.
|
||||
await $.scrollUntilVisible(finder: $("Rotate identity key"));
|
||||
await $("Rotate identity key").tap();
|
||||
await $.pumpAndSettle();
|
||||
await $("Rotate").tap();
|
||||
await $(RegExp("rotated; new key")).waitUntilVisible();
|
||||
await $.pump(const Duration(seconds: 5));
|
||||
|
||||
// Re-resolve: the chain validates, so the outcome is the rotation
|
||||
// banner, and the superseded key moves to the §8 history section.
|
||||
await $("Contacts").tap();
|
||||
await $.pumpAndSettle();
|
||||
await $(client.accountAddress()!.short).tap();
|
||||
await $.pumpAndSettle();
|
||||
await $("Re-resolve").tap();
|
||||
await $(RegExp("rotated its key; a signed chain confirms it"))
|
||||
.waitUntilVisible(timeout: const Duration(seconds: 30));
|
||||
await $("previous keys (1)").waitUntilVisible();
|
||||
await $("Dismiss").tap();
|
||||
|
||||
// Mail sealed to the old key: the master still derives it, so the
|
||||
// letter reads exactly as before the rotation. The AppBar's back arrow
|
||||
// pops the detail route; the native back press is avoided here because
|
||||
// it killed the patrol connection at this point in an earlier run.
|
||||
await $(Icons.arrow_back).tap();
|
||||
await $.pumpAndSettle();
|
||||
await $("Mail").tap();
|
||||
await $.pumpAndSettle();
|
||||
await $("Requests (1)").tap();
|
||||
await $.pumpAndSettle();
|
||||
await $("rotation subject").tap();
|
||||
await $.pumpAndSettle();
|
||||
await $("rotation body before the key change").waitUntilVisible();
|
||||
});
|
||||
|
||||
// Checklist item 14: re-resolving a contact whose key did not change is a
|
||||
// quiet confirmation — no banner, no history section.
|
||||
patrolTest("smoke: re-resolving an unchanged contact is quiet", ($) async {
|
||||
final container = await boot($);
|
||||
await onboard($, container);
|
||||
final client = container.read(clientProvider);
|
||||
final short = client.accountAddress()!.short;
|
||||
|
||||
await importSelfContact($, container);
|
||||
|
||||
await $(short).tap();
|
||||
await $.pumpAndSettle();
|
||||
await $("Re-resolve").tap();
|
||||
await $("$short: key unchanged")
|
||||
.waitUntilVisible(timeout: const Duration(seconds: 30));
|
||||
// A banner would have carried the rotation warning instead; history
|
||||
// records a rotation, and there was none.
|
||||
expect($("previous keys (1)"), findsNothing);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
import "dart:convert";
|
||||
|
||||
import "package:dio/dio.dart";
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/data/models/account/account.dart";
|
||||
import "package:flash_mail/data/models/message/message.dart";
|
||||
import "package:flash_mail/data/models/message_detail/message_detail.dart";
|
||||
import "package:flash_mail/shared/utils/random_string.dart";
|
||||
|
||||
class ApiClient {
|
||||
final Dio dio;
|
||||
final Ref ref;
|
||||
|
||||
ApiClient(this.dio, this.ref);
|
||||
|
||||
Future<void> generateToken(String address, String password) async {
|
||||
final tokenResponse = await dio.post(
|
||||
"/token",
|
||||
data: jsonEncode({"address": address, "password": password}),
|
||||
);
|
||||
final Map<String, dynamic> tokenData =
|
||||
tokenResponse.data as Map<String, dynamic>;
|
||||
|
||||
ref.read(tokenProvider.notifier).state = tokenData["token"];
|
||||
}
|
||||
|
||||
Future<TMAccount> createAccount() async {
|
||||
final response = await dio.get("/domains");
|
||||
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
|
||||
final List<Map<String, dynamic>> domains = List.from(data["hydra:member"]);
|
||||
|
||||
final username = randomString(7);
|
||||
final domain = domains[0]["domain"];
|
||||
Map<String, String> payload = {
|
||||
"address": "$username@$domain",
|
||||
"password": randomString(10),
|
||||
};
|
||||
|
||||
final userResponse = await dio.post(
|
||||
"/accounts",
|
||||
data: jsonEncode(payload),
|
||||
);
|
||||
final Map<String, dynamic> info = userResponse.data as Map<String, dynamic>;
|
||||
payload["address"] = info["address"];
|
||||
|
||||
await generateToken(
|
||||
payload["address"] as String,
|
||||
payload["password"] as String,
|
||||
);
|
||||
|
||||
return TMAccount(
|
||||
id: info["id"],
|
||||
email: info["address"],
|
||||
password: payload["password"]!,
|
||||
quota: info["quota"],
|
||||
used: info["used"],
|
||||
);
|
||||
}
|
||||
|
||||
Future<TMAccount> login(String address, String password) async {
|
||||
final response = await dio.post(
|
||||
"/token",
|
||||
data: jsonEncode({"address": address, "password": password}),
|
||||
);
|
||||
final Map<String, dynamic> tokenData =
|
||||
response.data as Map<String, dynamic>;
|
||||
|
||||
ref.read(tokenProvider.notifier).state = tokenData["token"];
|
||||
final userId = tokenData["id"];
|
||||
|
||||
final options = Options(headers: {
|
||||
"Authorization": "Bearer ${tokenData["token"]}",
|
||||
});
|
||||
final userResponse = await dio.get("/accounts/$userId", options: options);
|
||||
final Map<String, dynamic> info = userResponse.data as Map<String, dynamic>;
|
||||
|
||||
return TMAccount(
|
||||
id: info["id"],
|
||||
email: address,
|
||||
password: password,
|
||||
quota: info["quota"],
|
||||
used: info["used"],
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<TMMessage>> fetchMessages(String email, String password) async {
|
||||
if (ref.read(tokenProvider) == null) {
|
||||
await generateToken(email, password);
|
||||
}
|
||||
|
||||
final token = ref.read(tokenProvider);
|
||||
final options = Options(headers: {"Authorization": "Bearer $token"});
|
||||
final response = await dio.get("/messages", options: options);
|
||||
|
||||
Map<String, dynamic> messageData = response.data as Map<String, dynamic>;
|
||||
final messages =
|
||||
List<Map<String, dynamic>>.from(messageData["hydra:member"]);
|
||||
|
||||
return messages.map((message) => TMMessage.fromJson(message)).toList();
|
||||
}
|
||||
|
||||
Future<TMMessageDetail> fetchMessageDetail(String id) async {
|
||||
if (ref.read(tokenProvider) == null) throw Exception("Token not found");
|
||||
|
||||
final token = ref.read(tokenProvider);
|
||||
final options = Options(headers: {"Authorization": "Bearer $token"});
|
||||
final response = await dio.get("/messages/$id", options: options);
|
||||
|
||||
Map<String, dynamic> messageData = response.data as Map<String, dynamic>;
|
||||
|
||||
return TMMessageDetail.fromJson(messageData);
|
||||
}
|
||||
|
||||
Future<void> deleteMessage(String id) async {
|
||||
if (ref.read(tokenProvider) == null) throw Exception("Token not found");
|
||||
|
||||
final token = ref.read(tokenProvider);
|
||||
final options = Options(headers: {"Authorization": "Bearer $token"});
|
||||
await dio.delete("/messages/$id", options: options);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
import "package:hive/hive.dart";
|
||||
|
||||
part "account.freezed.dart";
|
||||
part "account.g.dart";
|
||||
|
||||
@freezed
|
||||
class TMAccount with _$TMAccount {
|
||||
@HiveType(typeId: 0, adapterName: "AccountAdapter")
|
||||
factory TMAccount({
|
||||
@HiveField(0) required String id,
|
||||
@HiveField(1) required String email,
|
||||
@HiveField(2) required String password,
|
||||
@HiveField(3) required int quota,
|
||||
@HiveField(4) required int used,
|
||||
}) = _TMAccount;
|
||||
|
||||
factory TMAccount.fromJson(Map<String, dynamic> json) =>
|
||||
_$TMAccountFromJson(json);
|
||||
}
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'account.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
|
||||
|
||||
TMAccount _$TMAccountFromJson(Map<String, dynamic> json) {
|
||||
return _TMAccount.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$TMAccount {
|
||||
@HiveField(0)
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
@HiveField(1)
|
||||
String get email => throw _privateConstructorUsedError;
|
||||
@HiveField(2)
|
||||
String get password => throw _privateConstructorUsedError;
|
||||
@HiveField(3)
|
||||
int get quota => throw _privateConstructorUsedError;
|
||||
@HiveField(4)
|
||||
int get used => throw _privateConstructorUsedError;
|
||||
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@JsonKey(ignore: true)
|
||||
$TMAccountCopyWith<TMAccount> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $TMAccountCopyWith<$Res> {
|
||||
factory $TMAccountCopyWith(TMAccount value, $Res Function(TMAccount) then) =
|
||||
_$TMAccountCopyWithImpl<$Res, TMAccount>;
|
||||
@useResult
|
||||
$Res call(
|
||||
{@HiveField(0) String id,
|
||||
@HiveField(1) String email,
|
||||
@HiveField(2) String password,
|
||||
@HiveField(3) int quota,
|
||||
@HiveField(4) int used});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$TMAccountCopyWithImpl<$Res, $Val extends TMAccount>
|
||||
implements $TMAccountCopyWith<$Res> {
|
||||
_$TMAccountCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? email = null,
|
||||
Object? password = null,
|
||||
Object? quota = null,
|
||||
Object? used = null,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
email: null == email
|
||||
? _value.email
|
||||
: email // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
password: null == password
|
||||
? _value.password
|
||||
: password // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
quota: null == quota
|
||||
? _value.quota
|
||||
: quota // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
used: null == used
|
||||
? _value.used
|
||||
: used // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$TMAccountImplCopyWith<$Res>
|
||||
implements $TMAccountCopyWith<$Res> {
|
||||
factory _$$TMAccountImplCopyWith(
|
||||
_$TMAccountImpl value, $Res Function(_$TMAccountImpl) then) =
|
||||
__$$TMAccountImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{@HiveField(0) String id,
|
||||
@HiveField(1) String email,
|
||||
@HiveField(2) String password,
|
||||
@HiveField(3) int quota,
|
||||
@HiveField(4) int used});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$TMAccountImplCopyWithImpl<$Res>
|
||||
extends _$TMAccountCopyWithImpl<$Res, _$TMAccountImpl>
|
||||
implements _$$TMAccountImplCopyWith<$Res> {
|
||||
__$$TMAccountImplCopyWithImpl(
|
||||
_$TMAccountImpl _value, $Res Function(_$TMAccountImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? email = null,
|
||||
Object? password = null,
|
||||
Object? quota = null,
|
||||
Object? used = null,
|
||||
}) {
|
||||
return _then(_$TMAccountImpl(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
email: null == email
|
||||
? _value.email
|
||||
: email // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
password: null == password
|
||||
? _value.password
|
||||
: password // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
quota: null == quota
|
||||
? _value.quota
|
||||
: quota // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
used: null == used
|
||||
? _value.used
|
||||
: used // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
@HiveType(typeId: 0, adapterName: "AccountAdapter")
|
||||
class _$TMAccountImpl implements _TMAccount {
|
||||
_$TMAccountImpl(
|
||||
{@HiveField(0) required this.id,
|
||||
@HiveField(1) required this.email,
|
||||
@HiveField(2) required this.password,
|
||||
@HiveField(3) required this.quota,
|
||||
@HiveField(4) required this.used});
|
||||
|
||||
factory _$TMAccountImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$TMAccountImplFromJson(json);
|
||||
|
||||
@override
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
@override
|
||||
@HiveField(1)
|
||||
final String email;
|
||||
@override
|
||||
@HiveField(2)
|
||||
final String password;
|
||||
@override
|
||||
@HiveField(3)
|
||||
final int quota;
|
||||
@override
|
||||
@HiveField(4)
|
||||
final int used;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'TMAccount(id: $id, email: $email, password: $password, quota: $quota, used: $used)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(dynamic other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$TMAccountImpl &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.email, email) || other.email == email) &&
|
||||
(identical(other.password, password) ||
|
||||
other.password == password) &&
|
||||
(identical(other.quota, quota) || other.quota == quota) &&
|
||||
(identical(other.used, used) || other.used == used));
|
||||
}
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(runtimeType, id, email, password, quota, used);
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$TMAccountImplCopyWith<_$TMAccountImpl> get copyWith =>
|
||||
__$$TMAccountImplCopyWithImpl<_$TMAccountImpl>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$TMAccountImplToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _TMAccount implements TMAccount {
|
||||
factory _TMAccount(
|
||||
{@HiveField(0) required final String id,
|
||||
@HiveField(1) required final String email,
|
||||
@HiveField(2) required final String password,
|
||||
@HiveField(3) required final int quota,
|
||||
@HiveField(4) required final int used}) = _$TMAccountImpl;
|
||||
|
||||
factory _TMAccount.fromJson(Map<String, dynamic> json) =
|
||||
_$TMAccountImpl.fromJson;
|
||||
|
||||
@override
|
||||
@HiveField(0)
|
||||
String get id;
|
||||
@override
|
||||
@HiveField(1)
|
||||
String get email;
|
||||
@override
|
||||
@HiveField(2)
|
||||
String get password;
|
||||
@override
|
||||
@HiveField(3)
|
||||
int get quota;
|
||||
@override
|
||||
@HiveField(4)
|
||||
int get used;
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
_$$TMAccountImplCopyWith<_$TMAccountImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'account.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class AccountAdapter extends TypeAdapter<_$TMAccountImpl> {
|
||||
@override
|
||||
final int typeId = 0;
|
||||
|
||||
@override
|
||||
_$TMAccountImpl read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return _$TMAccountImpl(
|
||||
id: fields[0] as String,
|
||||
email: fields[1] as String,
|
||||
password: fields[2] as String,
|
||||
quota: fields[3] as int,
|
||||
used: fields[4] as int,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, _$TMAccountImpl obj) {
|
||||
writer
|
||||
..writeByte(5)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.email)
|
||||
..writeByte(2)
|
||||
..write(obj.password)
|
||||
..writeByte(3)
|
||||
..write(obj.quota)
|
||||
..writeByte(4)
|
||||
..write(obj.used);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AccountAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$TMAccountImpl _$$TMAccountImplFromJson(Map<String, dynamic> json) =>
|
||||
_$TMAccountImpl(
|
||||
id: json['id'] as String,
|
||||
email: json['email'] as String,
|
||||
password: json['password'] as String,
|
||||
quota: json['quota'] as int,
|
||||
used: json['used'] as int,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$TMAccountImplToJson(_$TMAccountImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'email': instance.email,
|
||||
'password': instance.password,
|
||||
'quota': instance.quota,
|
||||
'used': instance.used,
|
||||
};
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
|
||||
part "message.freezed.dart";
|
||||
part "message.g.dart";
|
||||
|
||||
@freezed
|
||||
class TMMessage with _$TMMessage {
|
||||
factory TMMessage({
|
||||
required String id,
|
||||
required Map<String, String> from,
|
||||
required List<Map<String, String>> to,
|
||||
required String? intro,
|
||||
required String subject,
|
||||
required bool seen,
|
||||
required bool isDeleted,
|
||||
required bool hasAttachments,
|
||||
required String downloadUrl,
|
||||
required DateTime createdAt,
|
||||
}) = _TMMessage;
|
||||
|
||||
factory TMMessage.fromJson(Map<String, dynamic> json) =>
|
||||
_$TMMessageFromJson(json);
|
||||
}
|
||||
|
|
@ -1,363 +0,0 @@
|
|||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'message.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
|
||||
|
||||
TMMessage _$TMMessageFromJson(Map<String, dynamic> json) {
|
||||
return _TMMessage.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$TMMessage {
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
Map<String, String> get from => throw _privateConstructorUsedError;
|
||||
List<Map<String, String>> get to => throw _privateConstructorUsedError;
|
||||
String? get intro => throw _privateConstructorUsedError;
|
||||
String get subject => throw _privateConstructorUsedError;
|
||||
bool get seen => throw _privateConstructorUsedError;
|
||||
bool get isDeleted => throw _privateConstructorUsedError;
|
||||
bool get hasAttachments => throw _privateConstructorUsedError;
|
||||
String get downloadUrl => throw _privateConstructorUsedError;
|
||||
DateTime get createdAt => throw _privateConstructorUsedError;
|
||||
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@JsonKey(ignore: true)
|
||||
$TMMessageCopyWith<TMMessage> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $TMMessageCopyWith<$Res> {
|
||||
factory $TMMessageCopyWith(TMMessage value, $Res Function(TMMessage) then) =
|
||||
_$TMMessageCopyWithImpl<$Res, TMMessage>;
|
||||
@useResult
|
||||
$Res call(
|
||||
{String id,
|
||||
Map<String, String> from,
|
||||
List<Map<String, String>> to,
|
||||
String? intro,
|
||||
String subject,
|
||||
bool seen,
|
||||
bool isDeleted,
|
||||
bool hasAttachments,
|
||||
String downloadUrl,
|
||||
DateTime createdAt});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$TMMessageCopyWithImpl<$Res, $Val extends TMMessage>
|
||||
implements $TMMessageCopyWith<$Res> {
|
||||
_$TMMessageCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? from = null,
|
||||
Object? to = null,
|
||||
Object? intro = freezed,
|
||||
Object? subject = null,
|
||||
Object? seen = null,
|
||||
Object? isDeleted = null,
|
||||
Object? hasAttachments = null,
|
||||
Object? downloadUrl = null,
|
||||
Object? createdAt = null,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
from: null == from
|
||||
? _value.from
|
||||
: from // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, String>,
|
||||
to: null == to
|
||||
? _value.to
|
||||
: to // ignore: cast_nullable_to_non_nullable
|
||||
as List<Map<String, String>>,
|
||||
intro: freezed == intro
|
||||
? _value.intro
|
||||
: intro // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
subject: null == subject
|
||||
? _value.subject
|
||||
: subject // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
seen: null == seen
|
||||
? _value.seen
|
||||
: seen // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
isDeleted: null == isDeleted
|
||||
? _value.isDeleted
|
||||
: isDeleted // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
hasAttachments: null == hasAttachments
|
||||
? _value.hasAttachments
|
||||
: hasAttachments // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
downloadUrl: null == downloadUrl
|
||||
? _value.downloadUrl
|
||||
: downloadUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$TMMessageImplCopyWith<$Res>
|
||||
implements $TMMessageCopyWith<$Res> {
|
||||
factory _$$TMMessageImplCopyWith(
|
||||
_$TMMessageImpl value, $Res Function(_$TMMessageImpl) then) =
|
||||
__$$TMMessageImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{String id,
|
||||
Map<String, String> from,
|
||||
List<Map<String, String>> to,
|
||||
String? intro,
|
||||
String subject,
|
||||
bool seen,
|
||||
bool isDeleted,
|
||||
bool hasAttachments,
|
||||
String downloadUrl,
|
||||
DateTime createdAt});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$TMMessageImplCopyWithImpl<$Res>
|
||||
extends _$TMMessageCopyWithImpl<$Res, _$TMMessageImpl>
|
||||
implements _$$TMMessageImplCopyWith<$Res> {
|
||||
__$$TMMessageImplCopyWithImpl(
|
||||
_$TMMessageImpl _value, $Res Function(_$TMMessageImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? from = null,
|
||||
Object? to = null,
|
||||
Object? intro = freezed,
|
||||
Object? subject = null,
|
||||
Object? seen = null,
|
||||
Object? isDeleted = null,
|
||||
Object? hasAttachments = null,
|
||||
Object? downloadUrl = null,
|
||||
Object? createdAt = null,
|
||||
}) {
|
||||
return _then(_$TMMessageImpl(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
from: null == from
|
||||
? _value._from
|
||||
: from // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, String>,
|
||||
to: null == to
|
||||
? _value._to
|
||||
: to // ignore: cast_nullable_to_non_nullable
|
||||
as List<Map<String, String>>,
|
||||
intro: freezed == intro
|
||||
? _value.intro
|
||||
: intro // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
subject: null == subject
|
||||
? _value.subject
|
||||
: subject // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
seen: null == seen
|
||||
? _value.seen
|
||||
: seen // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
isDeleted: null == isDeleted
|
||||
? _value.isDeleted
|
||||
: isDeleted // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
hasAttachments: null == hasAttachments
|
||||
? _value.hasAttachments
|
||||
: hasAttachments // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
downloadUrl: null == downloadUrl
|
||||
? _value.downloadUrl
|
||||
: downloadUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$TMMessageImpl implements _TMMessage {
|
||||
_$TMMessageImpl(
|
||||
{required this.id,
|
||||
required final Map<String, String> from,
|
||||
required final List<Map<String, String>> to,
|
||||
required this.intro,
|
||||
required this.subject,
|
||||
required this.seen,
|
||||
required this.isDeleted,
|
||||
required this.hasAttachments,
|
||||
required this.downloadUrl,
|
||||
required this.createdAt})
|
||||
: _from = from,
|
||||
_to = to;
|
||||
|
||||
factory _$TMMessageImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$TMMessageImplFromJson(json);
|
||||
|
||||
@override
|
||||
final String id;
|
||||
final Map<String, String> _from;
|
||||
@override
|
||||
Map<String, String> get from {
|
||||
if (_from is EqualUnmodifiableMapView) return _from;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_from);
|
||||
}
|
||||
|
||||
final List<Map<String, String>> _to;
|
||||
@override
|
||||
List<Map<String, String>> get to {
|
||||
if (_to is EqualUnmodifiableListView) return _to;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_to);
|
||||
}
|
||||
|
||||
@override
|
||||
final String? intro;
|
||||
@override
|
||||
final String subject;
|
||||
@override
|
||||
final bool seen;
|
||||
@override
|
||||
final bool isDeleted;
|
||||
@override
|
||||
final bool hasAttachments;
|
||||
@override
|
||||
final String downloadUrl;
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'TMMessage(id: $id, from: $from, to: $to, intro: $intro, subject: $subject, seen: $seen, isDeleted: $isDeleted, hasAttachments: $hasAttachments, downloadUrl: $downloadUrl, createdAt: $createdAt)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(dynamic other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$TMMessageImpl &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
const DeepCollectionEquality().equals(other._from, _from) &&
|
||||
const DeepCollectionEquality().equals(other._to, _to) &&
|
||||
(identical(other.intro, intro) || other.intro == intro) &&
|
||||
(identical(other.subject, subject) || other.subject == subject) &&
|
||||
(identical(other.seen, seen) || other.seen == seen) &&
|
||||
(identical(other.isDeleted, isDeleted) ||
|
||||
other.isDeleted == isDeleted) &&
|
||||
(identical(other.hasAttachments, hasAttachments) ||
|
||||
other.hasAttachments == hasAttachments) &&
|
||||
(identical(other.downloadUrl, downloadUrl) ||
|
||||
other.downloadUrl == downloadUrl) &&
|
||||
(identical(other.createdAt, createdAt) ||
|
||||
other.createdAt == createdAt));
|
||||
}
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
id,
|
||||
const DeepCollectionEquality().hash(_from),
|
||||
const DeepCollectionEquality().hash(_to),
|
||||
intro,
|
||||
subject,
|
||||
seen,
|
||||
isDeleted,
|
||||
hasAttachments,
|
||||
downloadUrl,
|
||||
createdAt);
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$TMMessageImplCopyWith<_$TMMessageImpl> get copyWith =>
|
||||
__$$TMMessageImplCopyWithImpl<_$TMMessageImpl>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$TMMessageImplToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _TMMessage implements TMMessage {
|
||||
factory _TMMessage(
|
||||
{required final String id,
|
||||
required final Map<String, String> from,
|
||||
required final List<Map<String, String>> to,
|
||||
required final String? intro,
|
||||
required final String subject,
|
||||
required final bool seen,
|
||||
required final bool isDeleted,
|
||||
required final bool hasAttachments,
|
||||
required final String downloadUrl,
|
||||
required final DateTime createdAt}) = _$TMMessageImpl;
|
||||
|
||||
factory _TMMessage.fromJson(Map<String, dynamic> json) =
|
||||
_$TMMessageImpl.fromJson;
|
||||
|
||||
@override
|
||||
String get id;
|
||||
@override
|
||||
Map<String, String> get from;
|
||||
@override
|
||||
List<Map<String, String>> get to;
|
||||
@override
|
||||
String? get intro;
|
||||
@override
|
||||
String get subject;
|
||||
@override
|
||||
bool get seen;
|
||||
@override
|
||||
bool get isDeleted;
|
||||
@override
|
||||
bool get hasAttachments;
|
||||
@override
|
||||
String get downloadUrl;
|
||||
@override
|
||||
DateTime get createdAt;
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
_$$TMMessageImplCopyWith<_$TMMessageImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'message.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$TMMessageImpl _$$TMMessageImplFromJson(Map<String, dynamic> json) =>
|
||||
_$TMMessageImpl(
|
||||
id: json['id'] as String,
|
||||
from: Map<String, String>.from(json['from'] as Map),
|
||||
to: (json['to'] as List<dynamic>)
|
||||
.map((e) => Map<String, String>.from(e as Map))
|
||||
.toList(),
|
||||
intro: json['intro'] as String?,
|
||||
subject: json['subject'] as String,
|
||||
seen: json['seen'] as bool,
|
||||
isDeleted: json['isDeleted'] as bool,
|
||||
hasAttachments: json['hasAttachments'] as bool,
|
||||
downloadUrl: json['downloadUrl'] as String,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$TMMessageImplToJson(_$TMMessageImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'from': instance.from,
|
||||
'to': instance.to,
|
||||
'intro': instance.intro,
|
||||
'subject': instance.subject,
|
||||
'seen': instance.seen,
|
||||
'isDeleted': instance.isDeleted,
|
||||
'hasAttachments': instance.hasAttachments,
|
||||
'downloadUrl': instance.downloadUrl,
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
};
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
|
||||
part "message_detail.freezed.dart";
|
||||
part "message_detail.g.dart";
|
||||
|
||||
@freezed
|
||||
class TMMessageDetail with _$TMMessageDetail {
|
||||
factory TMMessageDetail({
|
||||
required String id,
|
||||
required Map<String, String> from,
|
||||
required List<Map<String, String>> to,
|
||||
required List<String> cc,
|
||||
required List<String> bcc,
|
||||
required String subject,
|
||||
required String? text,
|
||||
required List<String> html,
|
||||
required bool hasAttachments,
|
||||
required List<Map<String, dynamic>>? attachments,
|
||||
required String downloadUrl,
|
||||
required DateTime createdAt,
|
||||
}) = _TMMessageDetail;
|
||||
|
||||
factory TMMessageDetail.fromJson(Map<String, dynamic> json) =>
|
||||
_$TMMessageDetailFromJson(json);
|
||||
}
|
||||
|
|
@ -1,438 +0,0 @@
|
|||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'message_detail.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
|
||||
|
||||
TMMessageDetail _$TMMessageDetailFromJson(Map<String, dynamic> json) {
|
||||
return _TMMessageDetail.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$TMMessageDetail {
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
Map<String, String> get from => throw _privateConstructorUsedError;
|
||||
List<Map<String, String>> get to => throw _privateConstructorUsedError;
|
||||
List<String> get cc => throw _privateConstructorUsedError;
|
||||
List<String> get bcc => throw _privateConstructorUsedError;
|
||||
String get subject => throw _privateConstructorUsedError;
|
||||
String? get text => throw _privateConstructorUsedError;
|
||||
List<String> get html => throw _privateConstructorUsedError;
|
||||
bool get hasAttachments => throw _privateConstructorUsedError;
|
||||
List<Map<String, dynamic>>? get attachments =>
|
||||
throw _privateConstructorUsedError;
|
||||
String get downloadUrl => throw _privateConstructorUsedError;
|
||||
DateTime get createdAt => throw _privateConstructorUsedError;
|
||||
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@JsonKey(ignore: true)
|
||||
$TMMessageDetailCopyWith<TMMessageDetail> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $TMMessageDetailCopyWith<$Res> {
|
||||
factory $TMMessageDetailCopyWith(
|
||||
TMMessageDetail value, $Res Function(TMMessageDetail) then) =
|
||||
_$TMMessageDetailCopyWithImpl<$Res, TMMessageDetail>;
|
||||
@useResult
|
||||
$Res call(
|
||||
{String id,
|
||||
Map<String, String> from,
|
||||
List<Map<String, String>> to,
|
||||
List<String> cc,
|
||||
List<String> bcc,
|
||||
String subject,
|
||||
String? text,
|
||||
List<String> html,
|
||||
bool hasAttachments,
|
||||
List<Map<String, dynamic>>? attachments,
|
||||
String downloadUrl,
|
||||
DateTime createdAt});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$TMMessageDetailCopyWithImpl<$Res, $Val extends TMMessageDetail>
|
||||
implements $TMMessageDetailCopyWith<$Res> {
|
||||
_$TMMessageDetailCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? from = null,
|
||||
Object? to = null,
|
||||
Object? cc = null,
|
||||
Object? bcc = null,
|
||||
Object? subject = null,
|
||||
Object? text = freezed,
|
||||
Object? html = null,
|
||||
Object? hasAttachments = null,
|
||||
Object? attachments = freezed,
|
||||
Object? downloadUrl = null,
|
||||
Object? createdAt = null,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
from: null == from
|
||||
? _value.from
|
||||
: from // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, String>,
|
||||
to: null == to
|
||||
? _value.to
|
||||
: to // ignore: cast_nullable_to_non_nullable
|
||||
as List<Map<String, String>>,
|
||||
cc: null == cc
|
||||
? _value.cc
|
||||
: cc // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
bcc: null == bcc
|
||||
? _value.bcc
|
||||
: bcc // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
subject: null == subject
|
||||
? _value.subject
|
||||
: subject // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
text: freezed == text
|
||||
? _value.text
|
||||
: text // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
html: null == html
|
||||
? _value.html
|
||||
: html // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
hasAttachments: null == hasAttachments
|
||||
? _value.hasAttachments
|
||||
: hasAttachments // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
attachments: freezed == attachments
|
||||
? _value.attachments
|
||||
: attachments // ignore: cast_nullable_to_non_nullable
|
||||
as List<Map<String, dynamic>>?,
|
||||
downloadUrl: null == downloadUrl
|
||||
? _value.downloadUrl
|
||||
: downloadUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$TMMessageDetailImplCopyWith<$Res>
|
||||
implements $TMMessageDetailCopyWith<$Res> {
|
||||
factory _$$TMMessageDetailImplCopyWith(_$TMMessageDetailImpl value,
|
||||
$Res Function(_$TMMessageDetailImpl) then) =
|
||||
__$$TMMessageDetailImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{String id,
|
||||
Map<String, String> from,
|
||||
List<Map<String, String>> to,
|
||||
List<String> cc,
|
||||
List<String> bcc,
|
||||
String subject,
|
||||
String? text,
|
||||
List<String> html,
|
||||
bool hasAttachments,
|
||||
List<Map<String, dynamic>>? attachments,
|
||||
String downloadUrl,
|
||||
DateTime createdAt});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$TMMessageDetailImplCopyWithImpl<$Res>
|
||||
extends _$TMMessageDetailCopyWithImpl<$Res, _$TMMessageDetailImpl>
|
||||
implements _$$TMMessageDetailImplCopyWith<$Res> {
|
||||
__$$TMMessageDetailImplCopyWithImpl(
|
||||
_$TMMessageDetailImpl _value, $Res Function(_$TMMessageDetailImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? from = null,
|
||||
Object? to = null,
|
||||
Object? cc = null,
|
||||
Object? bcc = null,
|
||||
Object? subject = null,
|
||||
Object? text = freezed,
|
||||
Object? html = null,
|
||||
Object? hasAttachments = null,
|
||||
Object? attachments = freezed,
|
||||
Object? downloadUrl = null,
|
||||
Object? createdAt = null,
|
||||
}) {
|
||||
return _then(_$TMMessageDetailImpl(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
from: null == from
|
||||
? _value._from
|
||||
: from // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, String>,
|
||||
to: null == to
|
||||
? _value._to
|
||||
: to // ignore: cast_nullable_to_non_nullable
|
||||
as List<Map<String, String>>,
|
||||
cc: null == cc
|
||||
? _value._cc
|
||||
: cc // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
bcc: null == bcc
|
||||
? _value._bcc
|
||||
: bcc // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
subject: null == subject
|
||||
? _value.subject
|
||||
: subject // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
text: freezed == text
|
||||
? _value.text
|
||||
: text // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
html: null == html
|
||||
? _value._html
|
||||
: html // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
hasAttachments: null == hasAttachments
|
||||
? _value.hasAttachments
|
||||
: hasAttachments // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
attachments: freezed == attachments
|
||||
? _value._attachments
|
||||
: attachments // ignore: cast_nullable_to_non_nullable
|
||||
as List<Map<String, dynamic>>?,
|
||||
downloadUrl: null == downloadUrl
|
||||
? _value.downloadUrl
|
||||
: downloadUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$TMMessageDetailImpl implements _TMMessageDetail {
|
||||
_$TMMessageDetailImpl(
|
||||
{required this.id,
|
||||
required final Map<String, String> from,
|
||||
required final List<Map<String, String>> to,
|
||||
required final List<String> cc,
|
||||
required final List<String> bcc,
|
||||
required this.subject,
|
||||
required this.text,
|
||||
required final List<String> html,
|
||||
required this.hasAttachments,
|
||||
required final List<Map<String, dynamic>>? attachments,
|
||||
required this.downloadUrl,
|
||||
required this.createdAt})
|
||||
: _from = from,
|
||||
_to = to,
|
||||
_cc = cc,
|
||||
_bcc = bcc,
|
||||
_html = html,
|
||||
_attachments = attachments;
|
||||
|
||||
factory _$TMMessageDetailImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$TMMessageDetailImplFromJson(json);
|
||||
|
||||
@override
|
||||
final String id;
|
||||
final Map<String, String> _from;
|
||||
@override
|
||||
Map<String, String> get from {
|
||||
if (_from is EqualUnmodifiableMapView) return _from;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_from);
|
||||
}
|
||||
|
||||
final List<Map<String, String>> _to;
|
||||
@override
|
||||
List<Map<String, String>> get to {
|
||||
if (_to is EqualUnmodifiableListView) return _to;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_to);
|
||||
}
|
||||
|
||||
final List<String> _cc;
|
||||
@override
|
||||
List<String> get cc {
|
||||
if (_cc is EqualUnmodifiableListView) return _cc;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_cc);
|
||||
}
|
||||
|
||||
final List<String> _bcc;
|
||||
@override
|
||||
List<String> get bcc {
|
||||
if (_bcc is EqualUnmodifiableListView) return _bcc;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_bcc);
|
||||
}
|
||||
|
||||
@override
|
||||
final String subject;
|
||||
@override
|
||||
final String? text;
|
||||
final List<String> _html;
|
||||
@override
|
||||
List<String> get html {
|
||||
if (_html is EqualUnmodifiableListView) return _html;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_html);
|
||||
}
|
||||
|
||||
@override
|
||||
final bool hasAttachments;
|
||||
final List<Map<String, dynamic>>? _attachments;
|
||||
@override
|
||||
List<Map<String, dynamic>>? get attachments {
|
||||
final value = _attachments;
|
||||
if (value == null) return null;
|
||||
if (_attachments is EqualUnmodifiableListView) return _attachments;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(value);
|
||||
}
|
||||
|
||||
@override
|
||||
final String downloadUrl;
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'TMMessageDetail(id: $id, from: $from, to: $to, cc: $cc, bcc: $bcc, subject: $subject, text: $text, html: $html, hasAttachments: $hasAttachments, attachments: $attachments, downloadUrl: $downloadUrl, createdAt: $createdAt)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(dynamic other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$TMMessageDetailImpl &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
const DeepCollectionEquality().equals(other._from, _from) &&
|
||||
const DeepCollectionEquality().equals(other._to, _to) &&
|
||||
const DeepCollectionEquality().equals(other._cc, _cc) &&
|
||||
const DeepCollectionEquality().equals(other._bcc, _bcc) &&
|
||||
(identical(other.subject, subject) || other.subject == subject) &&
|
||||
(identical(other.text, text) || other.text == text) &&
|
||||
const DeepCollectionEquality().equals(other._html, _html) &&
|
||||
(identical(other.hasAttachments, hasAttachments) ||
|
||||
other.hasAttachments == hasAttachments) &&
|
||||
const DeepCollectionEquality()
|
||||
.equals(other._attachments, _attachments) &&
|
||||
(identical(other.downloadUrl, downloadUrl) ||
|
||||
other.downloadUrl == downloadUrl) &&
|
||||
(identical(other.createdAt, createdAt) ||
|
||||
other.createdAt == createdAt));
|
||||
}
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
id,
|
||||
const DeepCollectionEquality().hash(_from),
|
||||
const DeepCollectionEquality().hash(_to),
|
||||
const DeepCollectionEquality().hash(_cc),
|
||||
const DeepCollectionEquality().hash(_bcc),
|
||||
subject,
|
||||
text,
|
||||
const DeepCollectionEquality().hash(_html),
|
||||
hasAttachments,
|
||||
const DeepCollectionEquality().hash(_attachments),
|
||||
downloadUrl,
|
||||
createdAt);
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$TMMessageDetailImplCopyWith<_$TMMessageDetailImpl> get copyWith =>
|
||||
__$$TMMessageDetailImplCopyWithImpl<_$TMMessageDetailImpl>(
|
||||
this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$TMMessageDetailImplToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _TMMessageDetail implements TMMessageDetail {
|
||||
factory _TMMessageDetail(
|
||||
{required final String id,
|
||||
required final Map<String, String> from,
|
||||
required final List<Map<String, String>> to,
|
||||
required final List<String> cc,
|
||||
required final List<String> bcc,
|
||||
required final String subject,
|
||||
required final String? text,
|
||||
required final List<String> html,
|
||||
required final bool hasAttachments,
|
||||
required final List<Map<String, dynamic>>? attachments,
|
||||
required final String downloadUrl,
|
||||
required final DateTime createdAt}) = _$TMMessageDetailImpl;
|
||||
|
||||
factory _TMMessageDetail.fromJson(Map<String, dynamic> json) =
|
||||
_$TMMessageDetailImpl.fromJson;
|
||||
|
||||
@override
|
||||
String get id;
|
||||
@override
|
||||
Map<String, String> get from;
|
||||
@override
|
||||
List<Map<String, String>> get to;
|
||||
@override
|
||||
List<String> get cc;
|
||||
@override
|
||||
List<String> get bcc;
|
||||
@override
|
||||
String get subject;
|
||||
@override
|
||||
String? get text;
|
||||
@override
|
||||
List<String> get html;
|
||||
@override
|
||||
bool get hasAttachments;
|
||||
@override
|
||||
List<Map<String, dynamic>>? get attachments;
|
||||
@override
|
||||
String get downloadUrl;
|
||||
@override
|
||||
DateTime get createdAt;
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
_$$TMMessageDetailImplCopyWith<_$TMMessageDetailImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'message_detail.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$TMMessageDetailImpl _$$TMMessageDetailImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$TMMessageDetailImpl(
|
||||
id: json['id'] as String,
|
||||
from: Map<String, String>.from(json['from'] as Map),
|
||||
to: (json['to'] as List<dynamic>)
|
||||
.map((e) => Map<String, String>.from(e as Map))
|
||||
.toList(),
|
||||
cc: (json['cc'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
bcc: (json['bcc'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
subject: json['subject'] as String,
|
||||
text: json['text'] as String?,
|
||||
html: (json['html'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
hasAttachments: json['hasAttachments'] as bool,
|
||||
attachments: (json['attachments'] as List<dynamic>?)
|
||||
?.map((e) => e as Map<String, dynamic>)
|
||||
.toList(),
|
||||
downloadUrl: json['downloadUrl'] as String,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$TMMessageDetailImplToJson(
|
||||
_$TMMessageDetailImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'from': instance.from,
|
||||
'to': instance.to,
|
||||
'cc': instance.cc,
|
||||
'bcc': instance.bcc,
|
||||
'subject': instance.subject,
|
||||
'text': instance.text,
|
||||
'html': instance.html,
|
||||
'hasAttachments': instance.hasAttachments,
|
||||
'attachments': instance.attachments,
|
||||
'downloadUrl': instance.downloadUrl,
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
};
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:hive_flutter/adapters.dart";
|
||||
|
||||
import "package:flash_mail/data/models/account/account.dart";
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
|
||||
class AccountState {
|
||||
final bool isLoading;
|
||||
final TMAccount? user;
|
||||
|
||||
const AccountState({
|
||||
this.isLoading = false,
|
||||
this.user,
|
||||
});
|
||||
|
||||
bool isAuthenticated() {
|
||||
return user != null;
|
||||
}
|
||||
}
|
||||
|
||||
class AccountNotifier extends StateNotifier<AccountState> {
|
||||
static const _boxName = "accounts";
|
||||
static const _key = "user";
|
||||
|
||||
final Ref ref;
|
||||
|
||||
AccountNotifier(this.ref) : super(const AccountState());
|
||||
|
||||
Future<void> fetchAccount() async {
|
||||
var box = await Hive.openBox<TMAccount>(_boxName);
|
||||
final user = box.get(_key, defaultValue: null);
|
||||
|
||||
if (user != null) {
|
||||
ref.read(emailProvider.notifier).state = user.email;
|
||||
ref.read(passwordProvider.notifier).state = user.password;
|
||||
state = AccountState(isLoading: false, user: user);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createAccount() async {
|
||||
state = const AccountState(isLoading: true);
|
||||
|
||||
try {
|
||||
TMAccount account = await ref.read(clientProvider).createAccount();
|
||||
var box = Hive.box<TMAccount>(_boxName);
|
||||
await box.put(_key, account);
|
||||
|
||||
ref.read(emailProvider.notifier).state = account.email;
|
||||
ref.read(passwordProvider.notifier).state = account.password;
|
||||
state = AccountState(isLoading: false, user: account);
|
||||
} catch (err) {
|
||||
state = const AccountState(isLoading: false);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> login(String? address, String? password) async {
|
||||
state = const AccountState(isLoading: true);
|
||||
|
||||
try {
|
||||
if (address != null && password != null) {
|
||||
TMAccount account =
|
||||
await ref.read(clientProvider).login(address, password);
|
||||
var box = Hive.box<TMAccount>(_boxName);
|
||||
await box.put(_key, account);
|
||||
|
||||
state = AccountState(isLoading: false, user: account);
|
||||
}
|
||||
} catch (err) {
|
||||
state = const AccountState(isLoading: false);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
AccountState previous = state;
|
||||
try {
|
||||
var box = Hive.box<TMAccount>(_boxName);
|
||||
await box.delete(_key);
|
||||
state = const AccountState();
|
||||
} catch (err) {
|
||||
state = previous;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +1,58 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:dio/dio.dart";
|
||||
|
||||
import "package:flash_mail/data/api/api_client.dart";
|
||||
import "package:flash_mail/data/models/message/message.dart";
|
||||
import "package:flash_mail/data/models/message_detail/message_detail.dart";
|
||||
import "package:flash_mail/data/notifiers/account_notifier.dart";
|
||||
import "package:smol_mail/smol/address.dart";
|
||||
import "package:smol_mail/smol/client.dart";
|
||||
import "package:smol_mail/smol/store.dart";
|
||||
|
||||
final emailProvider = StateProvider<String?>((ref) => null);
|
||||
final passwordProvider = StateProvider<String?>((ref) => null);
|
||||
/// Overridden in main() with the Hive-backed store opened at boot.
|
||||
final storeProvider = Provider<SmolStore>((ref) => throw UnimplementedError());
|
||||
|
||||
final dioProvider = Provider<Dio>(
|
||||
(ref) => Dio(
|
||||
BaseOptions(
|
||||
baseUrl: "https://api.mail.tm",
|
||||
connectTimeout: 2000,
|
||||
),
|
||||
),
|
||||
final clientProvider = Provider<SmolClient>(
|
||||
(ref) => SmolClient(ref.watch(storeProvider)),
|
||||
);
|
||||
|
||||
final clientProvider = Provider<ApiClient>(
|
||||
(ref) => ApiClient(ref.read(dioProvider), ref),
|
||||
);
|
||||
/// Hive gives no change notifications; every mail or state mutation bumps this,
|
||||
/// which is what the list, badge, identity and account providers watch.
|
||||
class Revision extends Notifier<int> {
|
||||
@override
|
||||
int build() => 0;
|
||||
|
||||
final accountProvider = StateNotifierProvider<AccountNotifier, AccountState>(
|
||||
(ref) => AccountNotifier(ref),
|
||||
);
|
||||
void bump() => state++;
|
||||
}
|
||||
|
||||
final tokenProvider = StateProvider<String?>((ref) => null);
|
||||
final revisionProvider = NotifierProvider<Revision, int>(Revision.new);
|
||||
|
||||
final messagesProvider = FutureProvider.autoDispose<List<TMMessage>>((ref) {
|
||||
final email = ref.read(emailProvider);
|
||||
final password = ref.read(passwordProvider);
|
||||
|
||||
return ref.read(clientProvider).fetchMessages(email!, password!);
|
||||
final identityProvider = Provider<SmolIdentity?>((ref) {
|
||||
ref.watch(revisionProvider);
|
||||
return ref.watch(storeProvider).identity();
|
||||
});
|
||||
|
||||
final messageDetailProvider = FutureProvider.family<TMMessageDetail, String>(
|
||||
(ref, id) => ref.read(clientProvider).fetchMessageDetail(id),
|
||||
);
|
||||
final accountProvider = Provider<SmolAddress?>((ref) {
|
||||
ref.watch(revisionProvider);
|
||||
return ref.watch(clientProvider).accountAddress();
|
||||
});
|
||||
|
||||
/// One tab (Inbox/Sent/Requests) can be on screen — and simultaneously kept
|
||||
/// alive off-screen by [TabBarView] for swiping — while another is active,
|
||||
/// so each is parameterized by its own folder rather than sharing one
|
||||
/// ambient "current folder" provider.
|
||||
final messagesForFolderProvider =
|
||||
Provider.family<List<MailRecord>, String>((ref, folder) {
|
||||
ref.watch(revisionProvider);
|
||||
return ref.watch(storeProvider).listMessages(folder);
|
||||
});
|
||||
|
||||
final unreadProvider = Provider<int>((ref) {
|
||||
ref.watch(revisionProvider);
|
||||
return ref.watch(storeProvider).unreadCount();
|
||||
});
|
||||
|
||||
final requestsUnreadProvider = Provider<int>((ref) {
|
||||
ref.watch(revisionProvider);
|
||||
return ref.watch(storeProvider).requestsUnreadCount();
|
||||
});
|
||||
|
||||
final contactsProvider = Provider<List<(String, StoredContact)>>((ref) {
|
||||
ref.watch(revisionProvider);
|
||||
return ref.watch(storeProvider).allContacts();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,23 +1,28 @@
|
|||
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:flutter_downloader/flutter_downloader.dart";
|
||||
import "package:hive_flutter/adapters.dart";
|
||||
import "package:hive_flutter/hive_flutter.dart";
|
||||
import "package:path_provider/path_provider.dart";
|
||||
|
||||
import "package:flash_mail/data/models/account/account.dart";
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flash_mail/presentation/app_widget.dart";
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/smol/config.dart";
|
||||
import "package:smol_mail/smol/store.dart";
|
||||
import "package:smol_mail/presentation/app_widget.dart";
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Hive.initFlutter();
|
||||
Hive.registerAdapter(AccountAdapter());
|
||||
ProviderContainer container = ProviderContainer();
|
||||
await container.read(accountProvider.notifier).fetchAccount();
|
||||
await FlutterDownloader.initialize();
|
||||
// Hive.initFlutter() defaults to getApplicationDocumentsDirectory(), which
|
||||
// on Linux resolves to the user's actual ~/Documents folder (XDG_DOCUMENTS_DIR)
|
||||
// rather than app-private storage — getApplicationSupportDirectory() is the
|
||||
// one that's actually sandboxed per-app on every platform.
|
||||
final dataDir = await getApplicationSupportDirectory();
|
||||
Hive.init(dataDir.path);
|
||||
final store = await SmolStore.open(dbPath: "${dataDir.path}/smol-mail.db");
|
||||
applyPresetServer(store);
|
||||
|
||||
runApp(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
ProviderScope(
|
||||
overrides: [storeProvider.overrideWithValue(store)],
|
||||
child: const AppWidget(),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
216
lib/native/client.dart
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// The async surface over the raw ABI: every operation runs on a short-lived
|
||||
// isolate via Isolate.run, because the Rust side blocks — a fetch can hold a
|
||||
// thread for seconds, which must never be the UI thread. Handles are plain
|
||||
// addresses (ints) and the master is bytes, so both cross isolates freely,
|
||||
// and fumi-core's `Send + Sync` store is safe to call from any of them.
|
||||
//
|
||||
// Error codes cross in two ranges: 0-10 are wire statuses, 64 up are local
|
||||
// failures (see ffi.dart) — recovery paths branch on the range. After a
|
||||
// smolPanic the store's lock is poisoned and every later call panics; the
|
||||
// UI treats that as "close and reopen the store", not an unsolvable error.
|
||||
|
||||
import "dart:isolate";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:smol_mail/native/ffi.dart";
|
||||
|
||||
class FumiNative {
|
||||
FumiNative(this.dbPath);
|
||||
|
||||
/// The SQLite file the store owns; one store per process.
|
||||
final String dbPath;
|
||||
|
||||
int? _store;
|
||||
int? _account;
|
||||
int? _flag;
|
||||
|
||||
/// The 32-byte master, held by the app (Hive in kirakira), never by the
|
||||
/// library.
|
||||
Uint8List? _master;
|
||||
|
||||
int? get store => _store;
|
||||
|
||||
/// The account handle, when a master is set; readers need it for describe.
|
||||
int? get account => _account;
|
||||
|
||||
/// Binds the account locally, as register and restore do at their end.
|
||||
void setAccount(String address) =>
|
||||
SmolFfi.open().setAccount(_store!, address);
|
||||
|
||||
Future<T> _io<T>(T Function(SmolFfi ffi) op) => Isolate.run(() {
|
||||
final ffi = SmolFfi.open();
|
||||
return op(ffi);
|
||||
});
|
||||
|
||||
Future<void> open() async {
|
||||
if (_store != null) return;
|
||||
_store = await _io((ffi) => ffi.openStore(dbPath));
|
||||
_flag = await _io((ffi) => ffi.newFlag());
|
||||
}
|
||||
|
||||
/// Drops the identity from memory the honest way Dart allows: the
|
||||
/// master's bytes are overwritten before the reference goes, and the
|
||||
/// account handle is freed. What persists is the caller's business
|
||||
/// (the store wipes its own copy).
|
||||
void clearMaster() {
|
||||
_master?.fillRange(0, _master!.length, 0);
|
||||
_master = null;
|
||||
final old = _account;
|
||||
if (old != null) {
|
||||
SmolFfi.open().freeAccount(old);
|
||||
}
|
||||
_account = null;
|
||||
}
|
||||
|
||||
/// Sets the master and rebuilds the account handle synchronously: the
|
||||
/// rebuild is a few key derivations (microseconds native), not a network
|
||||
/// operation, so it never needs an isolate — and widget tests can settle
|
||||
/// it inside their fake-async zones.
|
||||
void setMaster(Uint8List master, {int? rotations}) {
|
||||
// Own copy: the caller keeps its buffer (the onboarding screen zeroes
|
||||
// its reference on dispose, and wipe zeroes Hive's), and the account
|
||||
// rebuilds after register/restore/rotate must derive from untouched
|
||||
// bytes — a shared buffer zeroized elsewhere would rebuild an account
|
||||
// that matches none of our keys.
|
||||
_master = Uint8List.fromList(master);
|
||||
_rebuildAccount(rotations: rotations);
|
||||
}
|
||||
|
||||
/// Rebuilds the account handle from the master and the store's rotation
|
||||
/// index — required after register, restore and rotate, which all change
|
||||
/// the index the account stands at.
|
||||
void _rebuildAccount({int? rotations}) {
|
||||
final ffi = SmolFfi.open();
|
||||
final index = rotations ?? ffi.rotations(_store!);
|
||||
final old = _account;
|
||||
if (old != null) {
|
||||
ffi.freeAccount(old);
|
||||
}
|
||||
_account = ffi.newAccount(_master!, index);
|
||||
}
|
||||
|
||||
int get _s => _store!;
|
||||
int get _a => _account!;
|
||||
|
||||
Future<void> close() async {
|
||||
final store = _store;
|
||||
final account = _account;
|
||||
final flag = _flag;
|
||||
_store = null;
|
||||
_account = null;
|
||||
_flag = null;
|
||||
if (account != null) await _io((ffi) => ffi.freeAccount(account));
|
||||
if (flag != null) await _io((ffi) => ffi.freeFlag(flag));
|
||||
if (store != null) await _io((ffi) => ffi.closeStore(store));
|
||||
}
|
||||
|
||||
// --- identity and pins ------------------------------------------------------
|
||||
|
||||
Future<String> accountPk() => _io((ffi) => ffi.accountPk(_a));
|
||||
|
||||
Future<String?> accountAddress() => _io((ffi) => ffi.accountAddress(_s));
|
||||
|
||||
Future<int> rotations() => _io((ffi) => ffi.rotations(_s));
|
||||
|
||||
Future<void> pinServer(String host, String keyB32, int port) =>
|
||||
_io((ffi) => ffi.pinServer(_s, host, keyB32, port));
|
||||
|
||||
Future<String?> serverPin(String host, int port) =>
|
||||
_io((ffi) => ffi.serverPin(_s, host, port));
|
||||
|
||||
Future<void> unpinServer(String host, int port) =>
|
||||
_io((ffi) => ffi.unpinServer(_s, host, port));
|
||||
|
||||
Future<List<dynamic>> pins() => _io((ffi) => ffi.pins(_s));
|
||||
|
||||
Future<bool> syncOk() => _io((ffi) => ffi.syncOk(_s));
|
||||
|
||||
Future<void> saveContact(String address, String keyB32,
|
||||
{required bool verified}) =>
|
||||
_io((ffi) => ffi.saveContact(_s, address, keyB32, verified: verified));
|
||||
|
||||
// --- account lifecycle ------------------------------------------------------
|
||||
|
||||
Future<void> register(String address,
|
||||
{String? invite, String? dial, int timeout = 30}) =>
|
||||
_io((ffi) {
|
||||
ffi.register(_s, _a, address,
|
||||
invite: invite, dial: dial, timeout: timeout);
|
||||
}).then((_) => _rebuildAccount());
|
||||
|
||||
Future<void> restore(String address, {String? dial, int timeout = 30}) =>
|
||||
_io((ffi) =>
|
||||
ffi.restore(_s, _master!, address, dial: dial, timeout: timeout))
|
||||
.then((_) => _rebuildAccount());
|
||||
|
||||
Future<Map<String, dynamic>> rotate({String? dial, int timeout = 30}) =>
|
||||
_io((ffi) => ffi.rotate(_s, _a, dial: dial, timeout: timeout))
|
||||
.then((out) {
|
||||
_rebuildAccount();
|
||||
return out;
|
||||
});
|
||||
|
||||
// --- mail -------------------------------------------------------------------
|
||||
|
||||
/// Raises the cancellation flag; the running fetch stops between
|
||||
/// envelopes and returns a partial summary.
|
||||
void cancelFetch() => SmolFfi.open().setFlag(_flag ?? 0, true);
|
||||
|
||||
Future<Map<String, dynamic>> fetch(
|
||||
{bool keep = false,
|
||||
bool reset = false,
|
||||
String? dial,
|
||||
int timeout = 30}) =>
|
||||
_io((ffi) {
|
||||
ffi.setFlag(_flag!, false);
|
||||
return ffi.fetch(_s, _a,
|
||||
keep: keep, reset: reset, dial: dial, timeout: timeout,
|
||||
cancel: _flag);
|
||||
});
|
||||
|
||||
Future<Map<String, dynamic>> send(String to, String body,
|
||||
{String? subject, String? replyTo, String? dial, int timeout = 30}) =>
|
||||
_io((ffi) => ffi.send(_s, _a, to, body,
|
||||
subject: subject, replyTo: replyTo, dial: dial));
|
||||
|
||||
Future<void> delete(List<String> idsHex, {String? dial, int timeout = 30}) =>
|
||||
_io((ffi) => ffi.delete(_s, _a, idsHex, dial: dial, timeout: timeout));
|
||||
|
||||
/// The reader's delete: local removal plus seen-marking, no server round
|
||||
/// trip. Use [delete] for the server-side DELETE.
|
||||
Future<void> deleteLocal(String folder, List<String> idsHex) =>
|
||||
_io((ffi) => ffi.deleteLocal(_s, folder, idsHex));
|
||||
|
||||
Future<List<dynamic>> mail(String folder) =>
|
||||
_io((ffi) => ffi.mail(_s, folder));
|
||||
|
||||
Future<Map<String, dynamic>> describe(String idHex) =>
|
||||
_io((ffi) => ffi.describe(_s, _a, idHex));
|
||||
|
||||
// --- contacts ---------------------------------------------------------------
|
||||
|
||||
Future<List<dynamic>> contacts() => _io((ffi) => ffi.contacts(_s));
|
||||
|
||||
Future<Map<String, dynamic>> contact(String address) =>
|
||||
_io((ffi) => ffi.contact(_s, address));
|
||||
|
||||
Future<Map<String, dynamic>> resolve(String address,
|
||||
{String? dial, int timeout = 30}) =>
|
||||
_io((ffi) => ffi.resolve(_s, address, dial: dial, timeout: timeout));
|
||||
|
||||
Future<void> importContact(String uri) =>
|
||||
_io((ffi) => ffi.importContact(_s, uri));
|
||||
|
||||
Future<int> accept(String address, {String? dial, int timeout = 30}) =>
|
||||
_io((ffi) => ffi.accept(_s, _a, address, dial: dial, timeout: timeout));
|
||||
|
||||
Future<void> block(String address, {String? dial, int timeout = 30}) =>
|
||||
_io((ffi) => ffi.block(_s, _a, address, dial: dial, timeout: timeout));
|
||||
|
||||
// --- backups ----------------------------------------------------------------
|
||||
|
||||
Future<String> exportBackup() => _io((ffi) => ffi.exportBackup(_s, _master!));
|
||||
|
||||
Future<String> importBackup(String text) =>
|
||||
_io((ffi) => ffi.importBackup(_s, _master!, text));
|
||||
}
|
||||
522
lib/native/ffi.dart
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
// Raw FFI bindings for the C ABI in native/src/ffi.rs: one lookup per entry
|
||||
// point, no logic. Handles are opaque pointers at this layer; the typed
|
||||
// methods accept plain addresses (ints) so they cross isolates freely.
|
||||
// Every string this ABI returns is freed with smolFreeString — Dart's GC
|
||||
// runs no Rust destructor.
|
||||
|
||||
import "dart:convert";
|
||||
import "dart:ffi";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:ffi/ffi.dart";
|
||||
|
||||
import "package:smol_mail/native/library.dart";
|
||||
|
||||
// Stable error codes, mirrored from native/src/ffi.rs. The space is split
|
||||
// like the wire's: 0-10 are protocol status codes, verbatim, and local
|
||||
// failures live from 64 up, so a recovery path can be picked by range —
|
||||
// RATE_LIMITED (8) retries on the live session it arrived on, while
|
||||
// HANDSHAKE_REFUSED (71) means there is no session to retry on.
|
||||
const smolOk = 0;
|
||||
const smolMalformed = 1;
|
||||
const smolBadVersion = 2;
|
||||
const smolUnknownUser = 3;
|
||||
const smolAuthRequired = 4;
|
||||
const smolAuthFailed = 5;
|
||||
const smolQuotaExceeded = 6;
|
||||
const smolTooLarge = 7;
|
||||
const smolRateLimited = 8;
|
||||
const smolNotPermitted = 9;
|
||||
const smolInternalError = 10;
|
||||
/// Unassigned status byte; the raw byte is in the payload's "status".
|
||||
const smolUnknownStatus = 11;
|
||||
|
||||
const smolNotPinned = 64;
|
||||
const smolPinMismatch = 65;
|
||||
const smolKeyChanged = 66;
|
||||
const smolNotRegistered = 67;
|
||||
const smolChainLimit = 68;
|
||||
const smolSchemaVersion = 69;
|
||||
const smolUnreachable = 70;
|
||||
const smolHandshakeRefused = 71;
|
||||
const smolStorage = 72;
|
||||
const smolNoise = 73;
|
||||
const smolIo = 74;
|
||||
const smolOther = 75;
|
||||
const smolPanic = 76;
|
||||
|
||||
/// What a failed native call reports: the decision code plus the structured
|
||||
/// payload the ABI carries (host, known, offered — keys as base32).
|
||||
class NativeSmolException implements Exception {
|
||||
final int code;
|
||||
final String message;
|
||||
final Map<String, dynamic> details;
|
||||
|
||||
const NativeSmolException(this.code, this.message, this.details);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// The raw ABI. Opened per isolate — handles are plain addresses, so the
|
||||
/// same store can be called from any isolate, which is what the async
|
||||
/// wrapper relies on.
|
||||
class SmolFfi {
|
||||
SmolFfi._(this._lib);
|
||||
|
||||
final DynamicLibrary _lib;
|
||||
|
||||
static SmolFfi? _cached;
|
||||
|
||||
factory SmolFfi.open() => _cached ??= SmolFfi._(openSmolLibrary());
|
||||
|
||||
Pointer<Void> _h(int address) => Pointer<Void>.fromAddress(address);
|
||||
|
||||
late final Pointer<Utf8> Function() _lastError = _lib
|
||||
.lookupFunction<Pointer<Utf8> Function(), Pointer<Utf8> Function()>(
|
||||
"smol_last_error");
|
||||
late final void Function(Pointer<Utf8>) _freeString = _lib.lookupFunction<
|
||||
Void Function(Pointer<Utf8>),
|
||||
void Function(Pointer<Utf8>)>("smol_free_string");
|
||||
|
||||
late final Pointer<Void> Function(Pointer<Utf8>) _storeOpen = _lib
|
||||
.lookupFunction<Pointer<Void> Function(Pointer<Utf8>),
|
||||
Pointer<Void> Function(Pointer<Utf8>)>("smol_store_open");
|
||||
late final Pointer<Void> Function() _storeMemory = _lib.lookupFunction<
|
||||
Pointer<Void> Function(),
|
||||
Pointer<Void> Function()>("smol_store_memory");
|
||||
late final void Function(Pointer<Void>) _storeFree = _lib.lookupFunction<
|
||||
Void Function(Pointer<Void>),
|
||||
void Function(Pointer<Void>)>("smol_store_free");
|
||||
late final Pointer<Void> Function(Pointer<Uint8>, int) _accountNew = _lib
|
||||
.lookupFunction<
|
||||
Pointer<Void> Function(Pointer<Uint8>, Uint32),
|
||||
Pointer<Void> Function(
|
||||
Pointer<Uint8>, int)>("smol_account_new");
|
||||
late final void Function(Pointer<Void>) _accountFree = _lib.lookupFunction<
|
||||
Void Function(Pointer<Void>),
|
||||
void Function(Pointer<Void>)>("smol_account_free");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>) _accountPk = _lib
|
||||
.lookupFunction<Pointer<Utf8> Function(Pointer<Void>),
|
||||
Pointer<Utf8> Function(Pointer<Void>)>("smol_account_pk");
|
||||
late final Pointer<Void> Function() _flagNew = _lib.lookupFunction<
|
||||
Pointer<Void> Function(),
|
||||
Pointer<Void> Function()>("smol_flag_new");
|
||||
late final void Function(Pointer<Void>, int) _flagSet = _lib.lookupFunction<
|
||||
Void Function(Pointer<Void>, Int32),
|
||||
void Function(Pointer<Void>, int)>("smol_flag_set");
|
||||
late final void Function(Pointer<Void>) _flagFree = _lib.lookupFunction<
|
||||
Void Function(Pointer<Void>),
|
||||
void Function(Pointer<Void>)>("smol_flag_free");
|
||||
|
||||
late final int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, int)
|
||||
_pinServer = _lib.lookupFunction<
|
||||
Int32 Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, Uint16),
|
||||
int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>,
|
||||
int)>("smol_pin_server");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>, int)
|
||||
_serverPin = _lib.lookupFunction<
|
||||
Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>, Uint16),
|
||||
Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>, int)>(
|
||||
"smol_server_pin");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>) _storeAccount = _lib
|
||||
.lookupFunction<Pointer<Utf8> Function(Pointer<Void>),
|
||||
Pointer<Utf8> Function(Pointer<Void>)>("smol_store_account");
|
||||
late final int Function(Pointer<Void>) _rotations = _lib.lookupFunction<
|
||||
Int32 Function(Pointer<Void>),
|
||||
int Function(Pointer<Void>)>("smol_rotations");
|
||||
late final int Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>,
|
||||
Pointer<Utf8>, Pointer<Utf8>, int) _register = _lib.lookupFunction<
|
||||
Int32 Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>,
|
||||
Pointer<Utf8>, Uint64),
|
||||
int Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>,
|
||||
Pointer<Utf8>, int)
|
||||
>("smol_register");
|
||||
late final int Function(Pointer<Void>, Pointer<Uint8>, Pointer<Utf8>,
|
||||
Pointer<Utf8>, int) _restore = _lib.lookupFunction<
|
||||
Int32 Function(Pointer<Void>, Pointer<Uint8>, Pointer<Utf8>,
|
||||
Pointer<Utf8>, Uint64),
|
||||
int Function(Pointer<Void>, Pointer<Uint8>, Pointer<Utf8>,
|
||||
Pointer<Utf8>, int)>("smol_restore");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>,
|
||||
Pointer<Utf8>, int) _rotate = _lib.lookupFunction<
|
||||
Pointer<Utf8> Function(
|
||||
Pointer<Void>, Pointer<Void>, Pointer<Utf8>, Uint64),
|
||||
Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>,
|
||||
int)>("smol_rotate");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>, int, int,
|
||||
Pointer<Utf8>, int, Pointer<Void>) _fetch = _lib.lookupFunction<
|
||||
Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>, Int32, Int32,
|
||||
Pointer<Utf8>, Uint64, Pointer<Void>),
|
||||
Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>, int, int,
|
||||
Pointer<Utf8>, int, Pointer<Void>)>("smol_fetch");
|
||||
late final Pointer<Utf8> Function(
|
||||
Pointer<Void>,
|
||||
Pointer<Void>,
|
||||
Pointer<Utf8>,
|
||||
Pointer<Utf8>,
|
||||
Pointer<Utf8>,
|
||||
Pointer<Utf8>,
|
||||
int,
|
||||
int,
|
||||
Pointer<Utf8>,
|
||||
int) _send = _lib.lookupFunction<
|
||||
Pointer<Utf8> Function(
|
||||
Pointer<Void>,
|
||||
Pointer<Void>,
|
||||
Pointer<Utf8>,
|
||||
Pointer<Utf8>,
|
||||
Pointer<Utf8>,
|
||||
Pointer<Utf8>,
|
||||
Int32,
|
||||
Int32,
|
||||
Pointer<Utf8>,
|
||||
Uint64),
|
||||
Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>,
|
||||
Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>, int, int,
|
||||
Pointer<Utf8>, int)>("smol_send");
|
||||
late final int Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>,
|
||||
Pointer<Utf8>, int) _delete = _lib.lookupFunction<
|
||||
Int32 Function(
|
||||
Pointer<Void>, Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, Uint64),
|
||||
int Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>,
|
||||
Pointer<Utf8>, int)>("smol_delete");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>) _mail = _lib
|
||||
.lookupFunction<Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>),
|
||||
Pointer<Utf8> Function(
|
||||
Pointer<Void>, Pointer<Utf8>)>("smol_mail");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>,
|
||||
Pointer<Utf8>) _describe = _lib.lookupFunction<
|
||||
Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>),
|
||||
Pointer<Utf8> Function(
|
||||
Pointer<Void>, Pointer<Void>, Pointer<Utf8>)>("smol_describe");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>) _contacts = _lib
|
||||
.lookupFunction<Pointer<Utf8> Function(Pointer<Void>),
|
||||
Pointer<Utf8> Function(Pointer<Void>)>("smol_contacts");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>) _contact =
|
||||
_lib.lookupFunction<
|
||||
Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>),
|
||||
Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>)>(
|
||||
"smol_contact");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>,
|
||||
Pointer<Utf8>, Pointer<Utf8>, int) _accept = _lib.lookupFunction<
|
||||
Pointer<Utf8> Function(
|
||||
Pointer<Void>, Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, Uint64),
|
||||
Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>,
|
||||
Pointer<Utf8>, int)>("smol_accept");
|
||||
late final int Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>,
|
||||
Pointer<Utf8>, int) _block = _lib.lookupFunction<
|
||||
Int32 Function(
|
||||
Pointer<Void>, Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, Uint64),
|
||||
int Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>,
|
||||
Pointer<Utf8>, int)>("smol_block");
|
||||
late final Pointer<Utf8> Function(
|
||||
Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, int) _resolve = _lib
|
||||
.lookupFunction<
|
||||
Pointer<Utf8> Function(
|
||||
Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, Uint64),
|
||||
Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>,
|
||||
Pointer<Utf8>, int)>("smol_resolve");
|
||||
late final int Function(Pointer<Void>, Pointer<Utf8>) _accountSet = _lib
|
||||
.lookupFunction<Int32 Function(Pointer<Void>, Pointer<Utf8>),
|
||||
int Function(Pointer<Void>, Pointer<Utf8>)>("smol_account_set");
|
||||
late final int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, int)
|
||||
_contactSave = _lib.lookupFunction<
|
||||
Int32 Function(
|
||||
Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, Int32),
|
||||
int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>,
|
||||
int)>("smol_contact_save");
|
||||
late final int Function(Pointer<Void>, Pointer<Utf8>, int) _unpinServer =
|
||||
_lib.lookupFunction<
|
||||
Int32 Function(Pointer<Void>, Pointer<Utf8>, Uint16),
|
||||
int Function(
|
||||
Pointer<Void>, Pointer<Utf8>, int)>("smol_unpin_server");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>) _pins = _lib.lookupFunction<
|
||||
Pointer<Utf8> Function(Pointer<Void>),
|
||||
Pointer<Utf8> Function(Pointer<Void>)>("smol_pins");
|
||||
late final int Function(Pointer<Void>) _syncOk = _lib.lookupFunction<
|
||||
Int32 Function(Pointer<Void>),
|
||||
int Function(Pointer<Void>)>("smol_sync_ok");
|
||||
late final int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>)
|
||||
_deleteLocal = _lib.lookupFunction<
|
||||
Int32 Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>),
|
||||
int Function(
|
||||
Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>)>("smol_delete_local");
|
||||
late final Pointer<Utf8> Function(Pointer<Utf8>) _parseAddress = _lib
|
||||
.lookupFunction<Pointer<Utf8> Function(Pointer<Utf8>),
|
||||
Pointer<Utf8> Function(Pointer<Utf8>)>("smol_parse_address");
|
||||
late final int Function(Pointer<Void>, Pointer<Utf8>) _importContact = _lib
|
||||
.lookupFunction<Int32 Function(Pointer<Void>, Pointer<Utf8>),
|
||||
int Function(Pointer<Void>, Pointer<Utf8>)>("smol_import_contact");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Uint8>)
|
||||
_exportBackup = _lib.lookupFunction<
|
||||
Pointer<Utf8> Function(Pointer<Void>, Pointer<Uint8>),
|
||||
Pointer<Utf8> Function(
|
||||
Pointer<Void>, Pointer<Uint8>)>("smol_export_backup");
|
||||
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Uint8>,
|
||||
Pointer<Utf8>) _importBackup = _lib.lookupFunction<
|
||||
Pointer<Utf8> Function(
|
||||
Pointer<Void>, Pointer<Uint8>, Pointer<Utf8>),
|
||||
Pointer<Utf8> Function(
|
||||
Pointer<Void>, Pointer<Uint8>, Pointer<Utf8>)>(
|
||||
"smol_import_backup");
|
||||
|
||||
// --- helpers over the raw surface ------------------------------------------
|
||||
|
||||
/// Throws when a status call returned non-zero, decoding the error slot.
|
||||
/// The slot is thread-local: this only works because it runs inside the
|
||||
/// same Isolate.run closure that made the failing call — after the
|
||||
/// closure returns, the slot on that thread is gone.
|
||||
Never _fail() {
|
||||
final slot = _lastError();
|
||||
if (slot == nullptr) {
|
||||
throw const NativeSmolException(smolOther, "the native call failed", {});
|
||||
}
|
||||
final text = take(slot);
|
||||
final Map<String, dynamic> details;
|
||||
try {
|
||||
details = jsonDecode(text) as Map<String, dynamic>;
|
||||
} on FormatException {
|
||||
throw NativeSmolException(smolOther, text, {});
|
||||
}
|
||||
throw NativeSmolException(
|
||||
details["code"] as int? ?? smolOther,
|
||||
details["message"] as String? ?? "the native call failed",
|
||||
details);
|
||||
}
|
||||
|
||||
/// Reads one returned buffer into a String and frees the buffer — the
|
||||
/// ownership rule every caller owes a returned pointer.
|
||||
String take(Pointer<Utf8> ptr) {
|
||||
if (ptr == nullptr) _fail();
|
||||
final text = ptr.toDartString();
|
||||
_freeString(ptr);
|
||||
return text;
|
||||
}
|
||||
|
||||
Pointer<Utf8> _text(String text) => text.toNativeUtf8();
|
||||
|
||||
Pointer<Uint8> _bytes(Uint8List bytes) {
|
||||
final buf = malloc<Uint8>(32);
|
||||
buf.asTypedList(32).setAll(0, bytes);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// --- typed surface: handles cross as ints ----------------------------------
|
||||
|
||||
int openStore(String path) {
|
||||
final ptr = _storeOpen(_text(path));
|
||||
if (ptr == nullptr) _fail();
|
||||
return ptr.address;
|
||||
}
|
||||
|
||||
int openMemoryStore() {
|
||||
final ptr = _storeMemory();
|
||||
if (ptr == nullptr) _fail();
|
||||
return ptr.address;
|
||||
}
|
||||
|
||||
void closeStore(int store) => _storeFree(_h(store));
|
||||
|
||||
int newAccount(Uint8List master, int index) {
|
||||
final buf = _bytes(master);
|
||||
final ptr = _accountNew(buf, index);
|
||||
malloc.free(buf);
|
||||
if (ptr == nullptr) _fail();
|
||||
return ptr.address;
|
||||
}
|
||||
|
||||
void freeAccount(int account) => _accountFree(_h(account));
|
||||
|
||||
String accountPk(int account) => take(_accountPk(_h(account)));
|
||||
|
||||
int newFlag() => _flagNew().address;
|
||||
|
||||
void setFlag(int flag, bool value) => _flagSet(_h(flag), value ? 1 : 0);
|
||||
|
||||
void freeFlag(int flag) => _flagFree(_h(flag));
|
||||
|
||||
void pinServer(int store, String host, String keyB32, int port) {
|
||||
if (_pinServer(_h(store), _text(host), _text(keyB32), port) != smolOk) {
|
||||
_fail();
|
||||
}
|
||||
}
|
||||
|
||||
void unpinServer(int store, String host, int port) {
|
||||
if (_unpinServer(_h(store), _text(host), port) != smolOk) _fail();
|
||||
}
|
||||
|
||||
/// Every pin: [{host, key}] with keys as base32.
|
||||
List<dynamic> pins(int store) =>
|
||||
jsonDecode(take(_pins(_h(store)))) as List<dynamic>;
|
||||
|
||||
/// Whether the local accept-token set may replace the server's (sec 4).
|
||||
bool syncOk(int store) => _syncOk(_h(store)) == 1;
|
||||
|
||||
void saveContact(int store, String address, String keyB32,
|
||||
{required bool verified}) =>
|
||||
_contactSave(
|
||||
_h(store), _text(address), _text(keyB32), verified ? 1 : 0);
|
||||
|
||||
/// Null when nothing is pinned.
|
||||
String? serverPin(int store, String host, int port) {
|
||||
final text = take(_serverPin(_h(store), _text(host), port));
|
||||
return text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
/// Null when not registered.
|
||||
String? accountAddress(int store) {
|
||||
final text = take(_storeAccount(_h(store)));
|
||||
return text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
int rotations(int store) => _rotations(_h(store));
|
||||
|
||||
/// Binds the account locally, as register and restore do at their end.
|
||||
void setAccount(int store, String address) {
|
||||
if (_accountSet(_h(store), _text(address)) != smolOk) _fail();
|
||||
}
|
||||
|
||||
void register(int store, int account, String address,
|
||||
{String? invite, String? dial, int timeout = 30}) {
|
||||
if (_register(
|
||||
_h(store),
|
||||
_h(account),
|
||||
_text(address),
|
||||
invite == null ? nullptr : _text(invite),
|
||||
dial == null ? nullptr : _text(dial),
|
||||
timeout) !=
|
||||
smolOk) {
|
||||
_fail();
|
||||
}
|
||||
}
|
||||
|
||||
void restore(int store, Uint8List master, String address,
|
||||
{String? dial, int timeout = 30}) {
|
||||
final buf = _bytes(master);
|
||||
final code = _restore(_h(store), buf, _text(address),
|
||||
dial == null ? nullptr : _text(dial), timeout);
|
||||
malloc.free(buf);
|
||||
if (code != smolOk) _fail();
|
||||
}
|
||||
|
||||
Map<String, dynamic> rotate(int store, int account,
|
||||
{String? dial, int timeout = 30}) =>
|
||||
jsonDecode(take(_rotate(_h(store), _h(account),
|
||||
dial == null ? nullptr : _text(dial), timeout)))
|
||||
as Map<String, dynamic>;
|
||||
|
||||
Map<String, dynamic> fetch(int store, int account,
|
||||
{bool keep = false,
|
||||
bool reset = false,
|
||||
String? dial,
|
||||
int timeout = 30,
|
||||
int? cancel}) {
|
||||
final text = take(_fetch(_h(store), _h(account), keep ? 1 : 0, reset ? 1 : 0,
|
||||
dial == null ? nullptr : _text(dial), timeout,
|
||||
cancel == null ? nullptr : _h(cancel)));
|
||||
return jsonDecode(text) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Map<String, dynamic> send(int store, int account, String to, String body,
|
||||
{String? subject,
|
||||
String? replyTo,
|
||||
bool anonymous = false,
|
||||
bool noPad = false,
|
||||
String? dial,
|
||||
int timeout = 30}) {
|
||||
final text = take(_send(
|
||||
_h(store),
|
||||
_h(account),
|
||||
_text(to),
|
||||
subject == null ? nullptr : _text(subject),
|
||||
_text(body),
|
||||
replyTo == null ? nullptr : _text(replyTo),
|
||||
anonymous ? 1 : 0,
|
||||
noPad ? 1 : 0,
|
||||
dial == null ? nullptr : _text(dial),
|
||||
timeout));
|
||||
return jsonDecode(text) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Ids are 64 hex characters everywhere — the same shape `mail` and
|
||||
/// `describe` return, so no caller hex-decodes solely to delete.
|
||||
void delete(int store, int account, List<String> ids,
|
||||
{String? dial, int timeout = 30}) {
|
||||
if (_delete(_h(store), _h(account), _text(jsonEncode(ids)),
|
||||
dial == null ? nullptr : _text(dial), timeout) !=
|
||||
smolOk) {
|
||||
_fail();
|
||||
}
|
||||
}
|
||||
|
||||
List<dynamic> mail(int store, String folder) =>
|
||||
jsonDecode(take(_mail(_h(store), _text(folder)))) as List<dynamic>;
|
||||
|
||||
Map<String, dynamic> describe(int store, int account, String idHex) =>
|
||||
jsonDecode(take(_describe(_h(store), _h(account), _text(idHex))))
|
||||
as Map<String, dynamic>;
|
||||
|
||||
List<dynamic> contacts(int store) =>
|
||||
jsonDecode(take(_contacts(_h(store)))) as List<dynamic>;
|
||||
|
||||
Map<String, dynamic> contact(int store, String address) =>
|
||||
jsonDecode(take(_contact(_h(store), _text(address))))
|
||||
as Map<String, dynamic>;
|
||||
|
||||
int accept(int store, int account, String address,
|
||||
{String? dial, int timeout = 30}) =>
|
||||
(jsonDecode(take(_accept(_h(store), _h(account), _text(address),
|
||||
dial == null ? nullptr : _text(dial), timeout)))
|
||||
as Map<String, dynamic>)["held"] as int;
|
||||
|
||||
void block(int store, int account, String address,
|
||||
{String? dial, int timeout = 30}) {
|
||||
if (_block(_h(store), _h(account), _text(address),
|
||||
dial == null ? nullptr : _text(dial), timeout) !=
|
||||
smolOk) {
|
||||
_fail();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> resolve(int store, String address,
|
||||
{String? dial, int timeout = 30}) {
|
||||
final text = take(_resolve(_h(store), _text(address),
|
||||
dial == null ? nullptr : _text(dial), timeout));
|
||||
return jsonDecode(text) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// The reader's delete: removes locally and marks seen, so a kept
|
||||
/// server copy is not re-stored by the next fetch.
|
||||
void deleteLocal(int store, String folder, List<String> ids) {
|
||||
if (_deleteLocal(_h(store), _text(folder), _text(jsonEncode(ids))) !=
|
||||
smolOk) {
|
||||
_fail();
|
||||
}
|
||||
}
|
||||
|
||||
/// One address for the UI: {user, host, port, short, scheme}, or a
|
||||
/// NativeSmolException when it does not parse.
|
||||
Map<String, dynamic> parseAddress(String text) {
|
||||
return jsonDecode(take(_parseAddress(_text(text))))
|
||||
as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
void importContact(int store, String uri) {
|
||||
if (_importContact(_h(store), _text(uri)) != smolOk) _fail();
|
||||
}
|
||||
|
||||
String exportBackup(int store, Uint8List master) {
|
||||
final buf = _bytes(master);
|
||||
final text = take(_exportBackup(_h(store), buf));
|
||||
malloc.free(buf);
|
||||
return text;
|
||||
}
|
||||
|
||||
String importBackup(int store, Uint8List master, String text) {
|
||||
final buf = _bytes(master);
|
||||
final summary = take(_importBackup(_h(store), buf, _text(text)));
|
||||
malloc.free(buf);
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
13
lib/native/library.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Opens the native library (lib/smol -> fumi-core) the app links against.
|
||||
// Android bundles the cdylib under its plain soname; Linux loads the built
|
||||
// artifact from the checkout in dev/test and the bundle dir in release.
|
||||
|
||||
import "dart:ffi";
|
||||
import "dart:io";
|
||||
|
||||
DynamicLibrary openSmolLibrary() {
|
||||
if (Platform.isAndroid) {
|
||||
return DynamicLibrary.open("libsmol_mail_native.so");
|
||||
}
|
||||
return DynamicLibrary.open("native/target/release/libsmol_mail_native.so");
|
||||
}
|
||||
|
|
@ -1,19 +1,100 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:app_links/app_links.dart";
|
||||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/presentation/theme/theme.dart";
|
||||
import "package:flash_mail/presentation/routes/app_router.dart";
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
||||
import "package:smol_mail/presentation/theme/theme.dart";
|
||||
import "package:smol_mail/smol/link.dart";
|
||||
|
||||
class AppWidget extends ConsumerWidget {
|
||||
/// SnackBars need no context through this key, so the client's trust
|
||||
/// warnings can be shown from anywhere in the protocol stack.
|
||||
final rootScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
class AppWidget extends ConsumerStatefulWidget {
|
||||
const AppWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<AppWidget> createState() => _AppWidgetState();
|
||||
}
|
||||
|
||||
class _AppWidgetState extends ConsumerState<AppWidget> {
|
||||
late final AppRouter _router = AppRouter(ref);
|
||||
final AppLinks _appLinks = AppLinks();
|
||||
StreamSubscription<Uri>? _linkSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// §4/§8 trust warnings stay on screen until dismissed; ordinary notices
|
||||
// do not use this channel.
|
||||
ref.read(clientProvider).onWarning = (message) {
|
||||
rootScaffoldMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: const Color.fromRGBO(198, 40, 40, 1),
|
||||
content: Text(message, style: const TextStyle(color: Colors.white)),
|
||||
duration: const Duration(days: 1),
|
||||
action: SnackBarAction(
|
||||
label: "Dismiss",
|
||||
textColor: Colors.white,
|
||||
onPressed: () =>
|
||||
rootScaffoldMessengerKey.currentState?.hideCurrentSnackBar(),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
_linkSub = _appLinks.uriLinkStream.listen(_handleLink);
|
||||
}
|
||||
|
||||
// A smol:// link is the self-certifying form: the §8 strong path,
|
||||
// confirmed in the import dialog route before its key is bound. The push
|
||||
// waits a frame — the stream can deliver the cold-start link while the
|
||||
// first frame is still being laid out.
|
||||
void _handleLink(Uri uri) {
|
||||
final link = parseSmolLink(uri.toString());
|
||||
if (link == null) return;
|
||||
if (ref.read(identityProvider) == null) {
|
||||
rootScaffoldMessengerKey.currentState?.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("finish onboarding before using smol:// links")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _router.push(ImportContactDialogRoute(prefill: link.source)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_linkSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp.router(
|
||||
title: "FlashMail",
|
||||
theme: appTheme,
|
||||
title: "kirakira",
|
||||
theme: appThemeLight,
|
||||
darkTheme: appThemeDark,
|
||||
themeMode: ThemeMode.system,
|
||||
debugShowCheckedModeBanner: false,
|
||||
routerConfig: AppRouter(ref).config(),
|
||||
scaffoldMessengerKey: rootScaffoldMessengerKey,
|
||||
// smol:// URIs are for app_links, not the route table. The startup link
|
||||
// must still resolve to a real route — auto_route throws "Can not
|
||||
// resolve initial route" on an unmatched link and leaves a blank
|
||||
// screen — so it lands on the normal entry route, whose guard
|
||||
// redirects to home exactly like a launcher start. Every later
|
||||
// platform link is ignored by the router; the stream handler reacts.
|
||||
routerConfig: _router.config(
|
||||
deepLinkBuilder: (link) =>
|
||||
link.initial ? DeepLink.single(const OnboardingRoute()) : DeepLink.none,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
class AccountGuard extends AutoRouteGuard {
|
||||
final WidgetRef ref;
|
||||
|
||||
AccountGuard(this.ref);
|
||||
|
||||
@override
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) {
|
||||
if (!ref.read(accountProvider).isAuthenticated()) {
|
||||
router.replaceNamed("/");
|
||||
} else {
|
||||
resolver.next(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,27 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flash_mail/presentation/routes/account_guard.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/presentation/routes/auth_guard.dart";
|
||||
|
||||
import "package:smol_mail/presentation/routes/home_guard.dart";
|
||||
import "package:smol_mail/presentation/routes/identity_guard.dart";
|
||||
import "app_router.gr.dart";
|
||||
|
||||
/// A DialogRoute is a PageRoute whose settings carry the AutoRoutePage, so
|
||||
/// auto_route's setNewRoutePath — which pops pageless top routes before
|
||||
/// resolving a platform link — leaves dialog pages standing.
|
||||
Route<T> dialogRouteBuilder<T>(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
AutoRoutePage<T> page,
|
||||
) =>
|
||||
DialogRoute<T>(
|
||||
context: context,
|
||||
settings: page,
|
||||
builder: (ctx) => child,
|
||||
);
|
||||
|
||||
@AutoRouterConfig(replaceInRouteName: "Screen,Route")
|
||||
class AppRouter extends $AppRouter {
|
||||
class AppRouter extends RootStackRouter {
|
||||
final WidgetRef ref;
|
||||
|
||||
AppRouter(this.ref);
|
||||
|
|
@ -15,23 +29,41 @@ class AppRouter extends $AppRouter {
|
|||
@override
|
||||
List<AutoRoute> get routes => [
|
||||
AutoRoute(
|
||||
page: AccountRoute.page,
|
||||
page: OnboardingRoute.page,
|
||||
path: "/",
|
||||
initial: true,
|
||||
guards: [AuthGuard(ref)],
|
||||
guards: [HomeGuard(ref)],
|
||||
),
|
||||
AutoRoute(
|
||||
page: LoginRoute.page,
|
||||
path: "/login",
|
||||
),
|
||||
AutoRoute(
|
||||
page: InboxRoute.page,
|
||||
path: "/inbox",
|
||||
guards: [AccountGuard(ref)],
|
||||
page: HomeRoute.page,
|
||||
path: "/home",
|
||||
guards: [IdentityGuard(ref)],
|
||||
children: [
|
||||
AutoRoute(page: InboxRoute.page, path: "inbox", initial: true),
|
||||
AutoRoute(page: ContactsRoute.page, path: "contacts"),
|
||||
AutoRoute(page: SettingsRoute.page, path: "settings"),
|
||||
],
|
||||
),
|
||||
AutoRoute(
|
||||
page: MessageDetailRoute.page,
|
||||
path: "/message",
|
||||
)
|
||||
guards: [IdentityGuard(ref)],
|
||||
),
|
||||
AutoRoute(
|
||||
page: ComposeRoute.page,
|
||||
path: "/compose",
|
||||
guards: [IdentityGuard(ref)],
|
||||
),
|
||||
AutoRoute(
|
||||
page: ContactDetailRoute.page,
|
||||
path: "/contact",
|
||||
guards: [IdentityGuard(ref)],
|
||||
),
|
||||
CustomRoute(
|
||||
page: ImportContactDialogRoute.page,
|
||||
path: "/import",
|
||||
customRouteBuilder: dialogRouteBuilder,
|
||||
guards: [IdentityGuard(ref)],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// dart format width=80
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
// **************************************************************************
|
||||
|
|
@ -8,132 +9,312 @@
|
|||
// coverage:ignore-file
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:auto_route/auto_route.dart' as _i5;
|
||||
import 'package:flash_mail/presentation/screens/account_screen.dart' as _i1;
|
||||
import 'package:flash_mail/presentation/screens/inbox_screen.dart' as _i2;
|
||||
import 'package:flash_mail/presentation/screens/login_screen.dart' as _i3;
|
||||
import 'package:flash_mail/presentation/screens/message_detail_screen.dart'
|
||||
as _i4;
|
||||
import 'package:flutter/material.dart' as _i6;
|
||||
|
||||
abstract class $AppRouter extends _i5.RootStackRouter {
|
||||
$AppRouter({super.navigatorKey});
|
||||
import 'package:auto_route/auto_route.dart' as _i10;
|
||||
import 'package:flutter/material.dart' as _i11;
|
||||
import 'package:smol_mail/presentation/screens/compose_screen.dart' as _i1;
|
||||
import 'package:smol_mail/presentation/screens/contact_detail_screen.dart'
|
||||
as _i2;
|
||||
import 'package:smol_mail/presentation/screens/contacts_screen.dart' as _i3;
|
||||
import 'package:smol_mail/presentation/screens/home_screen.dart' as _i4;
|
||||
import 'package:smol_mail/presentation/screens/import_contact_dialog.dart'
|
||||
as _i5;
|
||||
import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i6;
|
||||
import 'package:smol_mail/presentation/screens/message_detail_screen.dart'
|
||||
as _i7;
|
||||
import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i8;
|
||||
import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i9;
|
||||
|
||||
/// generated route for
|
||||
/// [_i1.ComposeScreen]
|
||||
class ComposeRoute extends _i10.PageRouteInfo<ComposeRouteArgs> {
|
||||
ComposeRoute({
|
||||
_i11.Key? key,
|
||||
String? to,
|
||||
String? subject,
|
||||
List<_i10.PageRouteInfo>? children,
|
||||
}) : super(
|
||||
ComposeRoute.name,
|
||||
args: ComposeRouteArgs(key: key, to: to, subject: subject),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'ComposeRoute';
|
||||
|
||||
static _i10.PageInfo page = _i10.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<ComposeRouteArgs>(
|
||||
orElse: () => const ComposeRouteArgs(),
|
||||
);
|
||||
return _i1.ComposeScreen(
|
||||
key: args.key,
|
||||
to: args.to,
|
||||
subject: args.subject,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class ComposeRouteArgs {
|
||||
const ComposeRouteArgs({this.key, this.to, this.subject});
|
||||
|
||||
final _i11.Key? key;
|
||||
|
||||
final String? to;
|
||||
|
||||
final String? subject;
|
||||
|
||||
@override
|
||||
final Map<String, _i5.PageFactory> pagesMap = {
|
||||
AccountRoute.name: (routeData) {
|
||||
return _i5.AutoRoutePage<dynamic>(
|
||||
routeData: routeData,
|
||||
child: const _i1.AccountScreen(),
|
||||
);
|
||||
},
|
||||
InboxRoute.name: (routeData) {
|
||||
return _i5.AutoRoutePage<dynamic>(
|
||||
routeData: routeData,
|
||||
child: const _i2.InboxScreen(),
|
||||
);
|
||||
},
|
||||
LoginRoute.name: (routeData) {
|
||||
return _i5.AutoRoutePage<dynamic>(
|
||||
routeData: routeData,
|
||||
child: const _i3.LoginScreen(),
|
||||
);
|
||||
},
|
||||
MessageDetailRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<MessageDetailRouteArgs>();
|
||||
return _i5.AutoRoutePage<dynamic>(
|
||||
routeData: routeData,
|
||||
child: _i4.MessageDetailScreen(
|
||||
key: args.key,
|
||||
id: args.id,
|
||||
subject: args.subject,
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
String toString() {
|
||||
return 'ComposeRouteArgs{key: $key, to: $to, subject: $subject}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! ComposeRouteArgs) return false;
|
||||
return key == other.key && to == other.to && subject == other.subject;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => key.hashCode ^ to.hashCode ^ subject.hashCode;
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i1.AccountScreen]
|
||||
class AccountRoute extends _i5.PageRouteInfo<void> {
|
||||
const AccountRoute({List<_i5.PageRouteInfo>? children})
|
||||
: super(
|
||||
AccountRoute.name,
|
||||
initialChildren: children,
|
||||
);
|
||||
/// [_i2.ContactDetailScreen]
|
||||
class ContactDetailRoute extends _i10.PageRouteInfo<ContactDetailRouteArgs> {
|
||||
ContactDetailRoute({
|
||||
_i11.Key? key,
|
||||
required String address,
|
||||
List<_i10.PageRouteInfo>? children,
|
||||
}) : super(
|
||||
ContactDetailRoute.name,
|
||||
args: ContactDetailRouteArgs(key: key, address: address),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'AccountRoute';
|
||||
static const String name = 'ContactDetailRoute';
|
||||
|
||||
static const _i5.PageInfo<void> page = _i5.PageInfo<void>(name);
|
||||
static _i10.PageInfo page = _i10.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<ContactDetailRouteArgs>();
|
||||
return _i2.ContactDetailScreen(key: args.key, address: args.address);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class ContactDetailRouteArgs {
|
||||
const ContactDetailRouteArgs({this.key, required this.address});
|
||||
|
||||
final _i11.Key? key;
|
||||
|
||||
final String address;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ContactDetailRouteArgs{key: $key, address: $address}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! ContactDetailRouteArgs) return false;
|
||||
return key == other.key && address == other.address;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => key.hashCode ^ address.hashCode;
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i2.InboxScreen]
|
||||
class InboxRoute extends _i5.PageRouteInfo<void> {
|
||||
const InboxRoute({List<_i5.PageRouteInfo>? children})
|
||||
: super(
|
||||
InboxRoute.name,
|
||||
initialChildren: children,
|
||||
);
|
||||
/// [_i3.ContactsScreen]
|
||||
class ContactsRoute extends _i10.PageRouteInfo<void> {
|
||||
const ContactsRoute({List<_i10.PageRouteInfo>? children})
|
||||
: super(ContactsRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'ContactsRoute';
|
||||
|
||||
static _i10.PageInfo page = _i10.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i3.ContactsScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i4.HomeScreen]
|
||||
class HomeRoute extends _i10.PageRouteInfo<void> {
|
||||
const HomeRoute({List<_i10.PageRouteInfo>? children})
|
||||
: super(HomeRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'HomeRoute';
|
||||
|
||||
static _i10.PageInfo page = _i10.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i4.HomeScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i5.ImportContactDialogScreen]
|
||||
class ImportContactDialogRoute
|
||||
extends _i10.PageRouteInfo<ImportContactDialogRouteArgs> {
|
||||
ImportContactDialogRoute({
|
||||
_i11.Key? key,
|
||||
String? prefill,
|
||||
List<_i10.PageRouteInfo>? children,
|
||||
}) : super(
|
||||
ImportContactDialogRoute.name,
|
||||
args: ImportContactDialogRouteArgs(key: key, prefill: prefill),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'ImportContactDialogRoute';
|
||||
|
||||
static _i10.PageInfo page = _i10.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<ImportContactDialogRouteArgs>(
|
||||
orElse: () => const ImportContactDialogRouteArgs(),
|
||||
);
|
||||
return _i5.ImportContactDialogScreen(
|
||||
key: args.key,
|
||||
prefill: args.prefill,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class ImportContactDialogRouteArgs {
|
||||
const ImportContactDialogRouteArgs({this.key, this.prefill});
|
||||
|
||||
final _i11.Key? key;
|
||||
|
||||
final String? prefill;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImportContactDialogRouteArgs{key: $key, prefill: $prefill}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! ImportContactDialogRouteArgs) return false;
|
||||
return key == other.key && prefill == other.prefill;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => key.hashCode ^ prefill.hashCode;
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i6.InboxScreen]
|
||||
class InboxRoute extends _i10.PageRouteInfo<void> {
|
||||
const InboxRoute({List<_i10.PageRouteInfo>? children})
|
||||
: super(InboxRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'InboxRoute';
|
||||
|
||||
static const _i5.PageInfo<void> page = _i5.PageInfo<void>(name);
|
||||
static _i10.PageInfo page = _i10.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i6.InboxScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i3.LoginScreen]
|
||||
class LoginRoute extends _i5.PageRouteInfo<void> {
|
||||
const LoginRoute({List<_i5.PageRouteInfo>? children})
|
||||
: super(
|
||||
LoginRoute.name,
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'LoginRoute';
|
||||
|
||||
static const _i5.PageInfo<void> page = _i5.PageInfo<void>(name);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i4.MessageDetailScreen]
|
||||
class MessageDetailRoute extends _i5.PageRouteInfo<MessageDetailRouteArgs> {
|
||||
/// [_i7.MessageDetailScreen]
|
||||
class MessageDetailRoute extends _i10.PageRouteInfo<MessageDetailRouteArgs> {
|
||||
MessageDetailRoute({
|
||||
_i6.Key? key,
|
||||
_i11.Key? key,
|
||||
required String folder,
|
||||
required String id,
|
||||
required String subject,
|
||||
List<_i5.PageRouteInfo>? children,
|
||||
List<_i10.PageRouteInfo>? children,
|
||||
}) : super(
|
||||
MessageDetailRoute.name,
|
||||
args: MessageDetailRouteArgs(
|
||||
key: key,
|
||||
id: id,
|
||||
subject: subject,
|
||||
),
|
||||
initialChildren: children,
|
||||
);
|
||||
MessageDetailRoute.name,
|
||||
args: MessageDetailRouteArgs(key: key, folder: folder, id: id),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'MessageDetailRoute';
|
||||
|
||||
static const _i5.PageInfo<MessageDetailRouteArgs> page =
|
||||
_i5.PageInfo<MessageDetailRouteArgs>(name);
|
||||
static _i10.PageInfo page = _i10.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<MessageDetailRouteArgs>();
|
||||
return _i7.MessageDetailScreen(
|
||||
key: args.key,
|
||||
folder: args.folder,
|
||||
id: args.id,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class MessageDetailRouteArgs {
|
||||
const MessageDetailRouteArgs({
|
||||
this.key,
|
||||
required this.folder,
|
||||
required this.id,
|
||||
required this.subject,
|
||||
});
|
||||
|
||||
final _i6.Key? key;
|
||||
final _i11.Key? key;
|
||||
|
||||
final String folder;
|
||||
|
||||
final String id;
|
||||
|
||||
final String subject;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'MessageDetailRouteArgs{key: $key, id: $id, subject: $subject}';
|
||||
return 'MessageDetailRouteArgs{key: $key, folder: $folder, id: $id}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is! MessageDetailRouteArgs) return false;
|
||||
return key == other.key && folder == other.folder && id == other.id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => key.hashCode ^ folder.hashCode ^ id.hashCode;
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i8.OnboardingScreen]
|
||||
class OnboardingRoute extends _i10.PageRouteInfo<void> {
|
||||
const OnboardingRoute({List<_i10.PageRouteInfo>? children})
|
||||
: super(OnboardingRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'OnboardingRoute';
|
||||
|
||||
static _i10.PageInfo page = _i10.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i8.OnboardingScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i9.SettingsScreen]
|
||||
class SettingsRoute extends _i10.PageRouteInfo<void> {
|
||||
const SettingsRoute({List<_i10.PageRouteInfo>? children})
|
||||
: super(SettingsRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'SettingsRoute';
|
||||
|
||||
static _i10.PageInfo page = _i10.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i9.SettingsScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
class AuthGuard extends AutoRouteGuard {
|
||||
final WidgetRef ref;
|
||||
|
||||
AuthGuard(this.ref);
|
||||
|
||||
@override
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) {
|
||||
if (ref.read(accountProvider).isAuthenticated()) {
|
||||
router.replaceNamed("/inbox");
|
||||
} else {
|
||||
resolver.next(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
lib/presentation/routes/home_guard.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
||||
|
||||
/// The landing screen is only for users who cannot use the app yet: no
|
||||
/// identity, or no registered account.
|
||||
class HomeGuard extends AutoRouteGuard {
|
||||
final WidgetRef ref;
|
||||
|
||||
HomeGuard(this.ref);
|
||||
|
||||
@override
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) {
|
||||
if (ref.read(identityProvider) != null &&
|
||||
ref.read(accountProvider) != null) {
|
||||
router.replace(HomeRoute());
|
||||
} else {
|
||||
resolver.next(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
lib/presentation/routes/identity_guard.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
||||
|
||||
/// Everything past onboarding needs an identity; the account can still be
|
||||
/// registered from settings.
|
||||
class IdentityGuard extends AutoRouteGuard {
|
||||
final WidgetRef ref;
|
||||
|
||||
IdentityGuard(this.ref);
|
||||
|
||||
@override
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) {
|
||||
if (ref.read(identityProvider) == null) {
|
||||
router.replace(OnboardingRoute());
|
||||
} else {
|
||||
resolver.next(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:auto_route/auto_route.dart";
|
||||
|
||||
import "package:flash_mail/presentation/widgets/auth/account_buttons.dart";
|
||||
|
||||
@RoutePage()
|
||||
class AccountScreen extends StatelessWidget {
|
||||
const AccountScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: <Widget>[
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Image(
|
||||
image: AssetImage("assets/images/logo-small.png"),
|
||||
),
|
||||
Text(
|
||||
"FlashMail",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
)
|
||||
],
|
||||
),
|
||||
const AccountButtons()
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
127
lib/presentation/screens/compose_screen.dart
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/widgets/primary_button.dart";
|
||||
import "package:smol_mail/presentation/widgets/small_loading_spinner.dart";
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
|
||||
/// One tap seals one envelope; the button disables while the send is in
|
||||
/// flight so a second tap cannot seal a second message.
|
||||
@RoutePage()
|
||||
class ComposeScreen extends ConsumerStatefulWidget {
|
||||
final String? to;
|
||||
final String? subject;
|
||||
|
||||
const ComposeScreen({
|
||||
super.key,
|
||||
this.to,
|
||||
this.subject,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ComposeScreen> createState() => _ComposeScreenState();
|
||||
}
|
||||
|
||||
class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
late final toController = TextEditingController(text: widget.to ?? "");
|
||||
late final subjectController =
|
||||
TextEditingController(text: widget.subject ?? "");
|
||||
final bodyController = TextEditingController();
|
||||
bool anonymous = false;
|
||||
bool busy = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
toController.dispose();
|
||||
subjectController.dispose();
|
||||
bodyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final client = ref.read(clientProvider);
|
||||
setState(() => busy = true);
|
||||
try {
|
||||
final to = await client.send(
|
||||
toController.text,
|
||||
subjectController.text,
|
||||
bodyController.text,
|
||||
anonymous: anonymous,
|
||||
);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text("sent to $to")));
|
||||
AutoRouter.of(context).pop();
|
||||
}
|
||||
} on SmolError catch (err) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, err.message);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Write")),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: toController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "To",
|
||||
hintText: "user@host[:1961] or smol://user@host/key",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
TextField(
|
||||
controller: subjectController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Subject",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
TextField(
|
||||
controller: bodyController,
|
||||
maxLines: 12,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Body",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: anonymous,
|
||||
onChanged: (value) => setState(() => anonymous = value),
|
||||
title: Text(
|
||||
"Anonymous (omit the signed Reply-To)",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Center(
|
||||
child: PrimaryButton(
|
||||
onPressed: busy ? () {} : _send,
|
||||
child: busy ? const SmallLoadingSpinner() : const Text("Send"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
218
lib/presentation/screens/contact_detail_screen.dart
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/shared/utils/format.dart";
|
||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||
import "package:smol_mail/smol/address.dart";
|
||||
import "package:smol_mail/presentation/widgets/qr_code_sheet.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/ui.dart";
|
||||
|
||||
/// The full key, the address to hand out, and any key this one replaced —
|
||||
/// the row can only afford a fingerprint, and §8 turns on the user being able
|
||||
/// to inspect exactly what changed.
|
||||
@RoutePage()
|
||||
class ContactDetailScreen extends ConsumerStatefulWidget {
|
||||
final String address;
|
||||
|
||||
const ContactDetailScreen({super.key, required this.address});
|
||||
|
||||
@override
|
||||
ConsumerState<ContactDetailScreen> createState() =>
|
||||
_ContactDetailScreenState();
|
||||
}
|
||||
|
||||
class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
|
||||
bool resolving = false;
|
||||
bool tokenBusy = false;
|
||||
|
||||
// §5.8: admitting or withdrawing a contact's accept token pushes the
|
||||
// change to the server right away, since it only takes effect once the
|
||||
// server holds the changed set.
|
||||
Future<void> _toggleAccepted(bool currentlyAccepted) async {
|
||||
final client = ref.read(clientProvider);
|
||||
setState(() => tokenBusy = true);
|
||||
try {
|
||||
if (currentlyAccepted) {
|
||||
await client.blockContact(widget.address);
|
||||
} else {
|
||||
await client.acceptContact(widget.address);
|
||||
}
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(currentlyAccepted
|
||||
? "${widget.address} blocked; their mail now lands in requests"
|
||||
: "${widget.address} accepted; their mail now lands in your main tier")),
|
||||
);
|
||||
}
|
||||
} on SmolError catch (err) {
|
||||
if (mounted) showErrorSnackBar(context, err.message);
|
||||
} finally {
|
||||
if (mounted) setState(() => tokenBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reResolve() async {
|
||||
final client = ref.read(clientProvider);
|
||||
setState(() => resolving = true);
|
||||
try {
|
||||
final outcome = await client.refreshContact(widget.address);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (mounted) {
|
||||
// A rotation or an unexplained key change is §8 material; it stays
|
||||
// until dismissed and stands out like any other warning.
|
||||
if (outcome.warn) {
|
||||
showErrorSnackBar(context, outcome.message,
|
||||
duration: const Duration(days: 1));
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(outcome.message)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} on SmolError catch (err) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, err.message);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => resolving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.watch(revisionProvider);
|
||||
final store = ref.read(storeProvider);
|
||||
final contact = store.contact(widget.address);
|
||||
final accepted = store.accepted(widget.address);
|
||||
final isAccepted = accepted != null && accepted.active;
|
||||
// Computed once per build; the branch below only renders it when a
|
||||
// contact is bound to the address, which is when the parse succeeds.
|
||||
final uri =
|
||||
contact == null ? null : parseAddress(widget.address).uri(contact.key);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.address)),
|
||||
body: contact == null
|
||||
? Center(
|
||||
child: Text("no such contact",
|
||||
style: Theme.of(context).textTheme.labelSmall))
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
_chip(context, contact.verified
|
||||
? "verified key"
|
||||
: "key pinned on first use"),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
"Accept token (SPEC.md §5.8)",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
isAccepted
|
||||
? "This contact's mail lands in your main tier."
|
||||
: "This contact's mail lands in requests until accepted.",
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
tokenBusy
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: TextButton.icon(
|
||||
onPressed: () => _toggleAccepted(isAccepted),
|
||||
icon: Icon(isAccepted ? Icons.block : Icons.check_circle_outline,
|
||||
size: 18),
|
||||
label: Text(isAccepted ? "Block" : "Accept"),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 20),
|
||||
_field(context, "fingerprint", fingerprint(contact.key)),
|
||||
_field(context, "public key", contact.key),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _field(context, "address", uri!),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
onPressed: () => copyText(context, uri),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: "Show QR code",
|
||||
icon: const Icon(Icons.qr_code, size: 18),
|
||||
onPressed: () => showQrCodeSheet(context, uri),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (contact.history.isNotEmpty) ...[
|
||||
const SizedBox(height: 30),
|
||||
Text("previous keys (${contact.history.length})",
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
"Replaced by a signed rotation chain (SPEC.md §7). Mail signed with these "
|
||||
"was sent before the change; rotation is not revocation, so a stolen key "
|
||||
"can still rotate onward.",
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
for (final entry in contact.history)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(fingerprint(entry.key)),
|
||||
Text(entry.key,
|
||||
style: Theme.of(context).textTheme.labelSmall),
|
||||
Text(
|
||||
"replaced ${formatTime(entry.until ~/ 1000)}",
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 30),
|
||||
resolving
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: TextButton.icon(
|
||||
onPressed: _reResolve,
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text("Re-resolve"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip(BuildContext context, String text) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).primaryColor),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall!
|
||||
.copyWith(color: Theme.of(context).primaryColor),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _field(BuildContext context, String label, String value) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.labelSmall),
|
||||
SelectableText(value, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
95
lib/presentation/screens/contacts_screen.dart
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
import "package:smol_mail/smol/ui.dart";
|
||||
|
||||
/// Contacts: the addresses bound to keys, with the trust badge and the number
|
||||
/// of keys each has replaced — the §8 surface for noticing rotations.
|
||||
@RoutePage()
|
||||
class ContactsScreen extends ConsumerWidget {
|
||||
const ContactsScreen({super.key});
|
||||
|
||||
void _import(BuildContext context) =>
|
||||
AutoRouter.of(context).push(ImportContactDialogRoute());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final contacts = ref.watch(contactsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Contacts"),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: "Import smol:// address",
|
||||
onPressed: () => _import(context),
|
||||
icon: const Icon(Icons.person_add),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: contacts.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
"no contacts yet — import a smol:// address, or write to someone",
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: contacts.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final (address, contact) = contacts[i];
|
||||
final accepted = ref.read(storeProvider).accepted(address);
|
||||
return ListTile(
|
||||
splashColor: Theme.of(context).extension<AppColors>()!.highlight,
|
||||
onTap: () => AutoRouter.of(context)
|
||||
.push(ContactDetailRoute(address: address)),
|
||||
title: Text(address),
|
||||
subtitle: Text(
|
||||
fingerprint(contact.key),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
trailing: Wrap(
|
||||
spacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_chip(context, contact.verified ? "verified" : "tofu",
|
||||
ok: contact.verified),
|
||||
if (contact.history.isNotEmpty)
|
||||
_chip(context, "${contact.history.length} previous",
|
||||
ok: false),
|
||||
if (accepted != null && accepted.active)
|
||||
_chip(context, "accepted", ok: true),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip(BuildContext context, String text, {required bool ok}) =>
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: ok
|
||||
? Theme.of(context).primaryColor
|
||||
: Theme.of(context).textTheme.labelSmall!.color!,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.labelSmall!.copyWith(
|
||||
color: ok
|
||||
? Theme.of(context).primaryColor
|
||||
: Theme.of(context).textTheme.labelSmall!.color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
86
lib/presentation/screens/home_screen.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
import "package:smol_mail/presentation/theme/breakpoints.dart";
|
||||
import "package:smol_mail/shared/configs/flash_mail_icons.dart";
|
||||
|
||||
class _Destination {
|
||||
final Widget icon;
|
||||
final String label;
|
||||
|
||||
const _Destination({required this.icon, required this.label});
|
||||
}
|
||||
|
||||
/// The app's only persistent navigation surface: a bottom [NavigationBar] on
|
||||
/// phone-width screens, a side [NavigationRail] on desktop-width ones — no
|
||||
/// hamburger drawer at either width. Mail/Contacts/Settings are switched by
|
||||
/// tapping only, never by swipe, so a horizontal gesture inside a
|
||||
/// destination (e.g. the Inbox/Sent/Requests tab bar) never changes section.
|
||||
@RoutePage()
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final unread = ref.watch(unreadProvider) + ref.watch(requestsUnreadProvider);
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
|
||||
final mailIcon = Icon(FlashMailIcons.inbox);
|
||||
final destinations = [
|
||||
_Destination(
|
||||
icon: unread > 0 ? Badge(label: Text("$unread"), child: mailIcon) : mailIcon,
|
||||
label: "Mail",
|
||||
),
|
||||
const _Destination(icon: Icon(Icons.contacts), label: "Contacts"),
|
||||
const _Destination(icon: Icon(Icons.settings), label: "Settings"),
|
||||
];
|
||||
|
||||
return AutoTabsRouter.builder(
|
||||
routes: const [InboxRoute(), ContactsRoute(), SettingsRoute()],
|
||||
builder: (context, children, tabsRouter) {
|
||||
final body = IndexedStack(index: tabsRouter.activeIndex, children: children);
|
||||
final wide = MediaQuery.sizeOf(context).width >= kDesktopBreakpoint;
|
||||
|
||||
if (!wide) {
|
||||
return Scaffold(
|
||||
body: body,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: tabsRouter.activeIndex,
|
||||
onDestinationSelected: tabsRouter.setActiveIndex,
|
||||
indicatorColor: appColors.highlight,
|
||||
destinations: [
|
||||
for (final d in destinations)
|
||||
NavigationDestination(icon: d.icon, label: d.label),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
selectedIndex: tabsRouter.activeIndex,
|
||||
onDestinationSelected: tabsRouter.setActiveIndex,
|
||||
backgroundColor: appColors.cardFill,
|
||||
useIndicator: true,
|
||||
indicatorColor: appColors.highlight,
|
||||
labelType: NavigationRailLabelType.all,
|
||||
destinations: [
|
||||
for (final d in destinations)
|
||||
NavigationRailDestination(icon: d.icon, label: Text(d.label)),
|
||||
],
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: body),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
131
lib/presentation/screens/import_contact_dialog.dart
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:mobile_scanner/mobile_scanner.dart";
|
||||
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/app_widget.dart";
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/link.dart";
|
||||
|
||||
/// The §8 strong path: a self-certifying smol:// address is pasted, scanned
|
||||
/// or deep-linked in, and the user confirms before its key is bound. A
|
||||
/// routed dialog page rather than showDialog, so auto_route's platform-link
|
||||
/// handling — which pops pageless top routes — leaves it standing. The QR
|
||||
/// icon swaps the text field for a camera view; a scanned URI only fills
|
||||
/// the field, the Import tap is still what binds the key.
|
||||
@RoutePage()
|
||||
class ImportContactDialogScreen extends ConsumerStatefulWidget {
|
||||
final String? prefill;
|
||||
|
||||
const ImportContactDialogScreen({super.key, this.prefill});
|
||||
|
||||
@override
|
||||
ConsumerState<ImportContactDialogScreen> createState() =>
|
||||
ImportContactDialogScreenState();
|
||||
}
|
||||
|
||||
class ImportContactDialogScreenState
|
||||
extends ConsumerState<ImportContactDialogScreen> {
|
||||
late final controller = TextEditingController(text: widget.prefill ?? "");
|
||||
bool scanning = false;
|
||||
MobileScannerController? _scanner;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scanner?.dispose();
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleScan() {
|
||||
setState(() {
|
||||
_scanner?.dispose();
|
||||
_scanner =
|
||||
scanning ? null : MobileScannerController(formats: [BarcodeFormat.qrCode]);
|
||||
scanning = !scanning;
|
||||
});
|
||||
}
|
||||
|
||||
// Only a URI fumi accepts fills the field; anything else the camera sees
|
||||
// is not for this dialog, so scanning simply continues.
|
||||
void _onDetect(BarcodeCapture capture) {
|
||||
if (!scanning) return;
|
||||
for (final barcode in capture.barcodes) {
|
||||
final raw = barcode.rawValue?.trim();
|
||||
if (raw != null && parseSmolLink(raw) != null) {
|
||||
controller.text = raw;
|
||||
_toggleScan();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _import() {
|
||||
final text = controller.text;
|
||||
if (text.trim().isEmpty) return;
|
||||
try {
|
||||
ref.read(clientProvider).importContact(text);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
rootScaffoldMessengerKey.currentState?.showSnackBar(
|
||||
const SnackBar(content: Text("contact imported (verified key)")),
|
||||
);
|
||||
AutoRouter.of(context).pop();
|
||||
} on SmolError catch (err) {
|
||||
showErrorSnackBar(context, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
title: const Text("Import contact"),
|
||||
content: scanning
|
||||
? SizedBox(
|
||||
height: 220,
|
||||
width: double.maxFinite,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: MobileScanner(
|
||||
controller: _scanner!,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
error.errorCode.name,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
onSubmitted: (_) => _import(),
|
||||
decoration: InputDecoration(
|
||||
hintText: "smol://user@host/key",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: scanning ? "Type the address" : "Scan a QR code",
|
||||
onPressed: _toggleScan,
|
||||
icon: Icon(scanning ? Icons.keyboard : Icons.qr_code_scanner),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _import,
|
||||
child: const Text("Import"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,21 @@
|
|||
import "dart:async";
|
||||
|
||||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flash_mail/presentation/widgets/drawer/app_drawer.dart";
|
||||
import "package:flash_mail/presentation/widgets/image_banner.dart";
|
||||
import "package:flash_mail/presentation/widgets/message/message_list.dart";
|
||||
import "package:flash_mail/presentation/widgets/shimmer/message_shimmer.dart";
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
||||
import "package:smol_mail/presentation/widgets/image_banner.dart";
|
||||
import "package:smol_mail/presentation/widgets/message/message_list.dart";
|
||||
import "package:smol_mail/presentation/widgets/qr_code_sheet.dart";
|
||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
|
||||
const _folders = ["inbox", "requests", "sent"];
|
||||
|
||||
/// No server push in v1 (SPEC.md §13): new mail arrives when the user taps
|
||||
/// fetch, which pulls everything and acknowledges what verifies. One FETCH
|
||||
/// call populates both Inbox and Requests (they're tiers of the same
|
||||
/// mailbox), so both — but not the purely local Sent folder — offer fetch.
|
||||
@RoutePage()
|
||||
class InboxScreen extends ConsumerStatefulWidget {
|
||||
const InboxScreen({super.key});
|
||||
|
|
@ -18,44 +24,116 @@ class InboxScreen extends ConsumerStatefulWidget {
|
|||
ConsumerState<InboxScreen> createState() => _InboxScreenState();
|
||||
}
|
||||
|
||||
class _InboxScreenState extends ConsumerState<InboxScreen> {
|
||||
Timer? _timer;
|
||||
class _InboxScreenState extends ConsumerState<InboxScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(timer) => ref.refresh(messagesProvider),
|
||||
);
|
||||
_tabController = TabController(length: _folders.length, vsync: this)
|
||||
..addListener(() {
|
||||
// Fires once settled, whether the change came from a tap or a swipe;
|
||||
// only the AppBar's fetch button depends on which tab is active.
|
||||
if (!_tabController.indexIsChanging) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _fetch(BuildContext context, WidgetRef ref) async {
|
||||
final client = ref.read(clientProvider);
|
||||
try {
|
||||
final summary = await client.fetch();
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (context.mounted) {
|
||||
final tail = summary.rejected.isEmpty
|
||||
? ""
|
||||
: "\n${summary.rejected.length} left on the server";
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("${summary.stored} new$tail")),
|
||||
);
|
||||
}
|
||||
} on SmolError catch (err) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _folderView(BuildContext context, WidgetRef ref, String folder) {
|
||||
final messages = ref.watch(messagesForFolderProvider(folder));
|
||||
final canFetch = folder != "sent";
|
||||
final onRefresh = canFetch ? () => _fetch(context, ref) : () => Future.value();
|
||||
|
||||
if (messages.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: onRefresh,
|
||||
child: ListView(
|
||||
children: const [
|
||||
ImageBanner(
|
||||
imgSrc: "assets/images/empty.png",
|
||||
text: "Nothing here yet!",
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return MessageList(folder: folder, onRefresh: onRefresh);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final messages = ref.watch(messagesProvider);
|
||||
final unread = ref.watch(unreadProvider);
|
||||
final requestsUnread = ref.watch(requestsUnreadProvider);
|
||||
final canFetch = _folders[_tabController.index] != "sent";
|
||||
// The same self-certifying URI the settings share row shows, one tap
|
||||
// closer: what a second device scans to import this identity.
|
||||
final me = ref.watch(identityProvider);
|
||||
final account = ref.watch(accountProvider);
|
||||
final share = (me == null || account == null)
|
||||
? null
|
||||
: account.uri(me.publicKey);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Inbox"),
|
||||
title: const Text("kirakira"),
|
||||
actions: [
|
||||
if (share != null)
|
||||
IconButton(
|
||||
tooltip: "Show QR code",
|
||||
onPressed: () => showQrCodeSheet(context, share),
|
||||
icon: const Icon(Icons.qr_code),
|
||||
),
|
||||
if (canFetch)
|
||||
IconButton(
|
||||
tooltip: "Fetch",
|
||||
onPressed: () => _fetch(context, ref),
|
||||
icon: const Icon(Icons.cloud_download),
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: [
|
||||
Tab(text: unread > 0 ? "Inbox ($unread)" : "Inbox"),
|
||||
Tab(text: requestsUnread > 0 ? "Requests ($requestsUnread)" : "Requests"),
|
||||
const Tab(text: "Sent"),
|
||||
],
|
||||
),
|
||||
),
|
||||
drawer: const AppDrawer(),
|
||||
body: messages.when(
|
||||
data: (data) => MessageList(
|
||||
messages: data,
|
||||
onRefresh: () => ref.refresh(messagesProvider.future),
|
||||
),
|
||||
error: (err, _) => const ImageBanner(
|
||||
imgSrc: "assets/images/error.png",
|
||||
text: "Something went wrong!",
|
||||
),
|
||||
loading: () => const MessageShimmer(),
|
||||
skipLoadingOnRefresh: true,
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => AutoRouter.of(context).push(ComposeRoute()),
|
||||
child: const Icon(Icons.edit),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
for (final folder in _folders) _folderView(context, ref, folder),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:auto_route/auto_route.dart";
|
||||
|
||||
import "package:flash_mail/presentation/widgets/auth/login_form.dart";
|
||||
|
||||
@RoutePage()
|
||||
class LoginScreen extends StatelessWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(25),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
"Login",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
LoginForm(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +1,135 @@
|
|||
|
||||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flash_mail/presentation/widgets/image_banner.dart";
|
||||
import "package:flash_mail/presentation/widgets/message/message_view.dart";
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/widgets/image_banner.dart";
|
||||
import "package:smol_mail/presentation/widgets/message/message_view.dart";
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/store.dart";
|
||||
|
||||
/// Deleting removes the local copy, and the server's too if "leave mail on
|
||||
/// server" left one there to remove (settings; SPEC.md §10).
|
||||
@RoutePage()
|
||||
class MessageDetailScreen extends ConsumerWidget {
|
||||
final String folder;
|
||||
final String id;
|
||||
final String subject;
|
||||
|
||||
const MessageDetailScreen({
|
||||
super.key,
|
||||
required this.folder,
|
||||
required this.id,
|
||||
required this.subject,
|
||||
});
|
||||
|
||||
void deleteMessage(BuildContext context, WidgetRef ref) async {
|
||||
await ref.read(clientProvider).deleteMessage(id);
|
||||
ref.invalidate(messagesProvider);
|
||||
Future<void> _delete(
|
||||
BuildContext context, WidgetRef ref, MailRecord record) async {
|
||||
try {
|
||||
await ref.read(clientProvider).deleteMessage(folder, record);
|
||||
} on SmolError catch (err) {
|
||||
if (context.mounted) showErrorSnackBar(context, err.message);
|
||||
return; // keep the local copy if a needed server delete failed
|
||||
}
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (context.mounted) {
|
||||
AutoRouter.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _nameSender(BuildContext context, WidgetRef ref,
|
||||
String senderKey) async {
|
||||
final client = ref.read(clientProvider);
|
||||
final controller = TextEditingController();
|
||||
final address = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: "user@host or smol://…",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(controller.text),
|
||||
child: const Text("Save"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (address == null || address.trim().isEmpty) return;
|
||||
try {
|
||||
await client.nameSender(address, senderKey);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Address saved for this sender")),
|
||||
);
|
||||
}
|
||||
} on SmolError catch (err) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final message = ref.watch(messageDetailProvider(id));
|
||||
final record = ref.watch(storeProvider).getMessage(folder, id);
|
||||
if (record == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: const ImageBanner(
|
||||
imgSrc: "assets/images/error.png",
|
||||
text: "Message not found!",
|
||||
),
|
||||
);
|
||||
}
|
||||
final client = ref.read(clientProvider);
|
||||
final opened = client.describe(record);
|
||||
final isSent = folder == "sent";
|
||||
final who = isSent
|
||||
? (record.recipient ?? "")
|
||||
: (opened.sender == null
|
||||
? ""
|
||||
: ref.watch(storeProvider).addressForKey(opened.sender!) ?? "");
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: who.isEmpty
|
||||
? null
|
||||
: Text(
|
||||
who,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 18,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => deleteMessage(context, ref),
|
||||
icon: const Icon(Icons.delete, color: Colors.white),
|
||||
)
|
||||
tooltip: "Delete",
|
||||
onPressed: () => _delete(context, ref, record),
|
||||
icon: const Icon(Icons.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
subject,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge!
|
||||
.copyWith(fontSize: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
message.when(
|
||||
data: (data) => MessageView(message: data),
|
||||
error: (err, _) => const ImageBanner(
|
||||
imgSrc: "assets/images/error.png",
|
||||
text: "Something went wrong!",
|
||||
),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
child: MessageView(
|
||||
folder: folder,
|
||||
record: record,
|
||||
opened: opened,
|
||||
onNameSender: opened.sender == null
|
||||
? null
|
||||
: (key) => _nameSender(context, ref, key),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
390
lib/presentation/screens/onboarding_screen.dart
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
||||
import "package:smol_mail/presentation/widgets/primary_button.dart";
|
||||
import "package:smol_mail/presentation/widgets/secondary_button.dart";
|
||||
import "package:smol_mail/presentation/widgets/small_loading_spinner.dart";
|
||||
import "package:smol_mail/smol/address.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/ui.dart";
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||
|
||||
/// First-run flow: create or restore an identity, back the seed up, pin the
|
||||
/// home server's key and register an address (SPEC.md §4: registration against
|
||||
/// an unpinned server is not permitted).
|
||||
@RoutePage()
|
||||
class OnboardingScreen extends ConsumerStatefulWidget {
|
||||
const OnboardingScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
|
||||
}
|
||||
|
||||
enum _Step { welcome, backup, restore, register }
|
||||
|
||||
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||
_Step step = _Step.welcome;
|
||||
Uint8List? createdMaster;
|
||||
bool busy = false;
|
||||
// The register step doubles as "pin a key to finish recalling" when a
|
||||
// restore's recall can't proceed without one yet — same fields, different
|
||||
// framing and default action, not the generic "register a new address" copy.
|
||||
bool recallIntent = false;
|
||||
// Set alongside recallIntent when we got here from Restore rather than from
|
||||
// "Already registered? Recall": there is no well-defined "register a new
|
||||
// address instead" fallback for a restored master until its rotation index
|
||||
// is known, so that escape hatch is hidden in this case (§2).
|
||||
bool restoreIntent = false;
|
||||
|
||||
final seedController = TextEditingController();
|
||||
final restoreAddressController = TextEditingController();
|
||||
final addressController = TextEditingController();
|
||||
final serverKeyController = TextEditingController();
|
||||
final tokenController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// The backup step's copy of the master is overwritten, not left to the
|
||||
// garbage collector — the honest zeroization Dart allows.
|
||||
createdMaster?.fillRange(0, createdMaster!.length, 0);
|
||||
createdMaster = null;
|
||||
seedController.dispose();
|
||||
restoreAddressController.dispose();
|
||||
addressController.dispose();
|
||||
serverKeyController.dispose();
|
||||
tokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showError(Object error) {
|
||||
if (!mounted) return;
|
||||
// Replace rather than queue: a fresh error must not wait behind a stale
|
||||
// one — a queued SnackBar is indistinguishable from "nothing happened".
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
showErrorSnackBar(
|
||||
context, error is SmolError ? error.message : error.toString());
|
||||
}
|
||||
|
||||
// Reaching onboarding at all means HomeGuard already found no complete
|
||||
// identity+account, so a master still sitting in the store here can only be
|
||||
// an abandoned attempt from earlier in this same flow (wrong master, failed
|
||||
// recall, "Back") — safe to replace rather than reject.
|
||||
// wipe() is fire-and-forget here, like every other store write in this
|
||||
// screen (setMaster, pinServer, ...) — Hive updates its in-memory state
|
||||
// synchronously and persists to disk in the background, so the identity
|
||||
// check right after is already consistent without awaiting the write.
|
||||
void _clearAbandonedIdentity() {
|
||||
final client = ref.read(clientProvider);
|
||||
if (client.identity != null && client.accountAddress() == null) {
|
||||
ref.read(storeProvider).wipe();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createIdentity() async {
|
||||
_clearAbandonedIdentity();
|
||||
final client = ref.read(clientProvider);
|
||||
try {
|
||||
final master = await client.createIdentity();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
createdMaster = master;
|
||||
step = _Step.backup;
|
||||
});
|
||||
} on Exception catch (err) {
|
||||
_showError(err);
|
||||
}
|
||||
}
|
||||
|
||||
void _restoreIdentity() {
|
||||
setState(() => step = _Step.restore);
|
||||
}
|
||||
|
||||
// §2: a master alone does not say which rotation index a server bound, so
|
||||
// restoring resolves the address and walks indices to find it — restore
|
||||
// always ends in a recall, never a bare local step.
|
||||
Future<void> _submitRestore() async {
|
||||
final addressText = restoreAddressController.text.trim();
|
||||
if (addressText.isEmpty) {
|
||||
_showError(const SmolError("enter the address this master was registered under"));
|
||||
return;
|
||||
}
|
||||
_clearAbandonedIdentity();
|
||||
// The register step has its own address field (it also needs a server
|
||||
// key, which restore doesn't collect) — carry over what was already
|
||||
// typed rather than making the user re-enter it.
|
||||
addressController.text = addressText;
|
||||
final client = ref.read(clientProvider);
|
||||
setState(() => busy = true);
|
||||
try {
|
||||
await client.restoreAndRecall(seedController.text, addressText);
|
||||
if (!mounted) return;
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
AutoRouter.of(context).replace(HomeRoute());
|
||||
} on Exception catch (err) {
|
||||
if (!mounted) return;
|
||||
// Most often the host just isn't pinned yet (SPEC.md §4) — the expected
|
||||
// state right after a restore, not a dead end — so land on the
|
||||
// recall-framed register step to collect a key and retry.
|
||||
_showError(err);
|
||||
setState(() {
|
||||
recallIntent = true;
|
||||
restoreIntent = true;
|
||||
step = _Step.register;
|
||||
});
|
||||
} finally {
|
||||
if (mounted) setState(() => busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitRegistration() async {
|
||||
await _submit(recall: false);
|
||||
}
|
||||
|
||||
// Recall binds a restored or already-created identity to its registered
|
||||
// address without re-REGISTER.
|
||||
Future<void> _submitRecall() async {
|
||||
await _submit(recall: true);
|
||||
}
|
||||
|
||||
Future<void> _submit({required bool recall}) async {
|
||||
final client = ref.read(clientProvider);
|
||||
setState(() => busy = true);
|
||||
try {
|
||||
final address = parseAddress(addressController.text);
|
||||
// Recall needs a pin too (SPEC.md §4), and the key for it is right
|
||||
// here in the form — pin it before either path. An empty field means
|
||||
// "already pinned, e.g. by a previous attempt or a preset server".
|
||||
final serverKey = serverKeyController.text.trim();
|
||||
if (serverKey.isNotEmpty) {
|
||||
client.pinServer(address.host, serverKey, address.port);
|
||||
}
|
||||
if (recall && restoreIntent) {
|
||||
await client.restoreAndRecall(seedController.text, address.short);
|
||||
} else if (recall) {
|
||||
await client.recallAccount(address.short);
|
||||
} else {
|
||||
await client.registerAccount(address.short,
|
||||
token: tokenController.text.trim());
|
||||
}
|
||||
if (mounted) {
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
AutoRouter.of(context).replace(HomeRoute());
|
||||
}
|
||||
} catch (err) {
|
||||
_showError(err);
|
||||
} finally {
|
||||
if (mounted) setState(() => busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25),
|
||||
child: switch (step) {
|
||||
_Step.welcome => _welcome(context),
|
||||
_Step.backup => _backup(context),
|
||||
_Step.restore => _restore(context),
|
||||
_Step.register => _register(context),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(BuildContext context, String title) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final logoWidth =
|
||||
(constraints.maxWidth * 0.5).clamp(48.0, 200.0).toDouble();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: logoWidth,
|
||||
child: const Image(
|
||||
image: AssetImage("assets/images/bug_logo.png"),
|
||||
fit: BoxFit.contain),
|
||||
),
|
||||
Text("キラキラ",
|
||||
style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 40),
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _welcome(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_header(context,
|
||||
"One keypair is the whole identity: an address, a key and a message."),
|
||||
const SizedBox(height: 40),
|
||||
PrimaryButton(
|
||||
onPressed: _createIdentity,
|
||||
child: const Text("Create Identity"),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
SecondaryButton(
|
||||
text: "Restore From Seed",
|
||||
onPressed: _restoreIdentity,
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _backup(BuildContext context) {
|
||||
final master = createdMaster!;
|
||||
final masterHex = hex(master);
|
||||
final publicKey = ref.read(clientProvider).identity!.publicKey;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_header(context, "Back up this master secret; it is the only secret."),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
"fingerprint:\n${fingerprint(publicKey)}",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SelectableText(
|
||||
masterHex,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall!
|
||||
.copyWith(color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: masterHex));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
const SnackBar(content: Text("Master secret copied to clipboard")),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text("Copy master secret"),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
PrimaryButton(
|
||||
onPressed: () => setState(() => step = _Step.register),
|
||||
child: const Text("I have backed it up"),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _restore(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_header(context, "Enter the 32-byte master secret as 64 hex characters."),
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: seedController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "64 hex characters",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
TextField(
|
||||
controller: restoreAddressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Address this master was registered under",
|
||||
hintText: "alice@example.org",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
PrimaryButton(
|
||||
onPressed: busy ? () {} : _submitRestore,
|
||||
child: busy ? const SmallLoadingSpinner() : const Text("Restore"),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
SecondaryButton(
|
||||
text: "Back",
|
||||
onPressed: busy ? () {} : () => setState(() => step = _Step.welcome),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _register(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_header(
|
||||
context,
|
||||
recallIntent
|
||||
? "Pin this server's public key to finish restoring your address."
|
||||
: "Register an address. Pin the server's public key first — obtain it from the operator through a trusted channel.",
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
TextField(
|
||||
controller: addressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Address",
|
||||
hintText: "you@example.org[:1961]",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
TextField(
|
||||
controller: serverKeyController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Server public key",
|
||||
hintText: "base32, 52 characters",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
if (!recallIntent) ...[
|
||||
const SizedBox(height: 15),
|
||||
TextField(
|
||||
controller: tokenController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Invite token (optional)",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 40),
|
||||
PrimaryButton(
|
||||
onPressed: busy
|
||||
? () {}
|
||||
: (recallIntent ? _submitRecall : _submitRegistration),
|
||||
child: busy
|
||||
? const SmallLoadingSpinner()
|
||||
: Text(recallIntent ? "Pin and Recall" : "Pin and Register"),
|
||||
),
|
||||
// Restoring a master has no well-defined "register instead" fallback
|
||||
// until its rotation index is resolved (§2), so that escape hatch is
|
||||
// only offered from the fresh-identity path.
|
||||
if (!restoreIntent)
|
||||
TextButton(
|
||||
onPressed: busy
|
||||
? () {}
|
||||
: (recallIntent ? _submitRegistration : _submitRecall),
|
||||
child: Text(recallIntent
|
||||
? "Register a new address instead"
|
||||
: "Already registered? Recall"),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
469
lib/presentation/screens/settings_screen.dart
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
import "dart:convert";
|
||||
import "dart:io";
|
||||
|
||||
import "package:auto_route/auto_route.dart";
|
||||
import "package:file_picker/file_picker.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:share_plus/share_plus.dart";
|
||||
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
import "package:smol_mail/presentation/widgets/qr_code_sheet.dart";
|
||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||
import "package:smol_mail/smol/address.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/ui.dart";
|
||||
|
||||
/// Identity card, server pins, rotation and the wipe. The seed is the only
|
||||
/// secret; revealing it is always an explicit action.
|
||||
@RoutePage()
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
bool seedShown = false;
|
||||
final hostController = TextEditingController();
|
||||
final keyController = TextEditingController();
|
||||
final addressController = TextEditingController();
|
||||
final tokenController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
hostController.dispose();
|
||||
keyController.dispose();
|
||||
addressController.dispose();
|
||||
tokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showError(Object error) {
|
||||
if (!mounted) return;
|
||||
showErrorSnackBar(context,
|
||||
error is SmolError ? error.message : "Something went wrong!");
|
||||
}
|
||||
|
||||
Future<void> _register() async {
|
||||
final client = ref.read(clientProvider);
|
||||
try {
|
||||
await client.registerAccount(addressController.text,
|
||||
token: tokenController.text.trim());
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("registered")),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
_showError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Mail, once fetched, exists only on this device — the server deletes it as
|
||||
// it is picked up (SPEC.md §5). This backs up inbox, sent mail, contacts and
|
||||
// pinned servers into one shareable file; never the seed.
|
||||
Future<void> _export() async {
|
||||
final store = ref.read(storeProvider);
|
||||
// exportData is the sealed gsmol container itself; the file body is its
|
||||
// JSON, already sealed to the master-derived key.
|
||||
final bytes = Uint8List.fromList(utf8.encode(await store.exportData()));
|
||||
final stamp = DateTime.now().toIso8601String().substring(0, 10);
|
||||
final fileName = "kirakira-backup-$stamp.json";
|
||||
try {
|
||||
// share_plus has no file-sharing implementation on desktop platforms —
|
||||
// it throws UnimplementedError for Linux/Windows/macOS — so those save
|
||||
// straight to a chosen location instead of going through a share sheet.
|
||||
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
||||
await FilePicker.saveFile(fileName: fileName, bytes: bytes);
|
||||
} else {
|
||||
final file = File("${Directory.systemTemp.path}/$fileName");
|
||||
await file.writeAsBytes(bytes);
|
||||
await SharePlus.instance.share(ShareParams(
|
||||
files: [XFile(file.path)], text: "kirakira backup $stamp"));
|
||||
}
|
||||
if (mounted) {
|
||||
// The export payload is sealed now (v2), so the counts for this
|
||||
// notice come straight from the store rather than the ciphertext.
|
||||
final messages = store.listMessages("inbox").length +
|
||||
store.listMessages("requests").length +
|
||||
store.listMessages("sent").length;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("exported $messages messages, "
|
||||
"${store.allContacts().length} contacts, "
|
||||
"${store.allPins().length} server keys")),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) showErrorSnackBar(context, err.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _import() async {
|
||||
final files = await FilePicker.pickFiles(
|
||||
type: FileType.custom, allowedExtensions: ["json"]);
|
||||
final path = files.isEmpty ? null : files.single.path;
|
||||
if (path == null) return;
|
||||
try {
|
||||
final summary =
|
||||
await ref.read(storeProvider).importData(File(path).readAsStringSync());
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("imported: $summary")),
|
||||
);
|
||||
}
|
||||
} on Exception catch (err) {
|
||||
_showError(err);
|
||||
} on TypeError catch (err) {
|
||||
_showError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Bind an address this seed was already registered under, without REGISTER.
|
||||
Future<void> _recall() async {
|
||||
final client = ref.read(clientProvider);
|
||||
try {
|
||||
final addr = await client.recallAccount(addressController.text);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("recalled ${addr.short}")),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
_showError(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _rotate() async {
|
||||
final client = ref.read(clientProvider);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
title: const Text("Rotate the identity key?"),
|
||||
content: const Text(
|
||||
"Contacts accept the change from the signed chain. The old key is kept, "
|
||||
"since mail sealed to it stays readable with nothing else."),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text("Cancel")),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text("Rotate")),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
final fresh = await client.rotateIdentity();
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("rotated; new key ${fresh.publicKey}")),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
_showError(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _wipe() async {
|
||||
final client = ref.read(clientProvider);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
title: const Text("Wipe everything?"),
|
||||
content: const Text(
|
||||
"Deletes the identity, every pinned key and every stored message from "
|
||||
"this device. Mail left on servers stays there."),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text("Cancel")),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text("Wipe")),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
await client.wipe();
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (mounted) {
|
||||
AutoRouter.of(context).replace(OnboardingRoute());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final store = ref.watch(storeProvider);
|
||||
final me = ref.watch(identityProvider);
|
||||
final account = ref.watch(accountProvider);
|
||||
if (me == null) return Scaffold(appBar: AppBar(title: const Text("Settings")));
|
||||
|
||||
final masterHex = hex(store.master()!);
|
||||
final share = account?.uri(me.publicKey);
|
||||
final pins = store.allPins();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Settings")),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
_card(
|
||||
context,
|
||||
children: [
|
||||
Text("fingerprint", style: Theme.of(context).textTheme.labelSmall),
|
||||
Text(fingerprint(me.publicKey)),
|
||||
const SizedBox(height: 15),
|
||||
Text("address", style: Theme.of(context).textTheme.labelSmall),
|
||||
Text(account?.short ?? "(not registered)"),
|
||||
const SizedBox(height: 15),
|
||||
Text("share", style: Theme.of(context).textTheme.labelSmall),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
share ?? "(register to get a shareable address)",
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (share != null) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
onPressed: () =>
|
||||
Clipboard.setData(ClipboardData(text: share)),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: "Show QR code",
|
||||
icon: const Icon(Icons.qr_code, size: 18),
|
||||
onPressed: () => showQrCodeSheet(context, share),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_card(
|
||||
context,
|
||||
borderColor: Theme.of(context).colorScheme.error,
|
||||
children: [
|
||||
Text("master secret", style: Theme.of(context).textTheme.labelSmall),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
seedShown ? masterHex : "•" * 64,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
setState(() => seedShown = !seedShown),
|
||||
child: Text(seedShown ? "hide" : "reveal"),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
onPressed: () =>
|
||||
Clipboard.setData(ClipboardData(text: masterHex)),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
"back this master secret up; it is the only secret",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall!
|
||||
.copyWith(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
"rotations: ${store.rotations()} (superseded keys kept to read old mail)",
|
||||
style: Theme.of(context).textTheme.labelSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"accepted correspondents: ${store.allAccepted().where((e) => e.$2.active).length}"
|
||||
"${store.syncOk() ? "" : " (not yet pushed to the server — accept them again)"}",
|
||||
style: Theme.of(context).textTheme.labelSmall),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text("Leave mail on server"),
|
||||
subtitle: const Text(
|
||||
"Fetch keeps a copy on the server instead of deleting it. "
|
||||
"Delete a message here to remove it from both."),
|
||||
value: store.leaveOnServer(),
|
||||
onChanged: (value) {
|
||||
store.setLeaveOnServer(value);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 20),
|
||||
Text("pinned servers", style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 10),
|
||||
for (final (host, key) in pins)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(host),
|
||||
subtitle: Text(
|
||||
key,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
onPressed: () {
|
||||
store.unpinServer(host, defaultPort);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
if (pins.isEmpty)
|
||||
Text("no pinned servers", style: Theme.of(context).textTheme.labelSmall),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: hostController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Host",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: keyController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Server public key (base32)",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
try {
|
||||
ref.read(clientProvider).pinServer(
|
||||
hostController.text, keyController.text, defaultPort);
|
||||
keyController.clear();
|
||||
setState(() {});
|
||||
} on SmolError catch (err) {
|
||||
_showError(err);
|
||||
} catch (err) {
|
||||
_showError(err);
|
||||
}
|
||||
},
|
||||
child: const Text("Pin server"),
|
||||
),
|
||||
if (account == null) ...[
|
||||
const SizedBox(height: 30),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 20),
|
||||
Text("register an address",
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: addressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Address",
|
||||
hintText: "you@example.org[:1961]",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: tokenController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Invite token (optional)",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: _register,
|
||||
child: const Text("Register"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _recall,
|
||||
child: const Text("Already registered? Recall"),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 30),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 20),
|
||||
Text("backup", style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
"Mail, once fetched, exists only on this device. Export backs up the "
|
||||
"inbox, sent mail, contacts and server pins — never the seed.",
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _export,
|
||||
icon: const Icon(Icons.share, size: 18),
|
||||
label: const Text("Export"),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _import,
|
||||
icon: const Icon(Icons.file_upload, size: 18),
|
||||
label: const Text("Import"),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 10),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text("Rotate identity key"),
|
||||
trailing: const Icon(Icons.rotate_right),
|
||||
onTap: _rotate,
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text("Wipe identity and messages",
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error)),
|
||||
trailing: Icon(Icons.delete_forever,
|
||||
color: Theme.of(context).colorScheme.error),
|
||||
onTap: _wipe,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card(BuildContext context,
|
||||
{required List<Widget> children, Color? borderColor}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: borderColor == null ? null : Border.all(color: borderColor),
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
78
lib/presentation/theme/app_colors.dart
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
/// Theme-aware colors that don't map onto a standard [ColorScheme] role.
|
||||
/// Access via `Theme.of(context).extension<AppColors>()!`.
|
||||
class AppColors extends ThemeExtension<AppColors> {
|
||||
const AppColors({
|
||||
required this.cardFill,
|
||||
required this.highlight,
|
||||
required this.snippetText,
|
||||
required this.avatarPalette,
|
||||
});
|
||||
|
||||
/// Elevated surface used for input fields, list tiles, and cards.
|
||||
final Color cardFill;
|
||||
|
||||
/// Pressed/selected state highlight (e.g. active drawer item).
|
||||
final Color highlight;
|
||||
|
||||
/// Dimmed body text, e.g. message preview snippets.
|
||||
final Color snippetText;
|
||||
|
||||
/// Deterministic background colors for contact avatars.
|
||||
final List<Color> avatarPalette;
|
||||
|
||||
static const dark = AppColors(
|
||||
cardFill: Color.fromRGBO(22, 39, 56, 1),
|
||||
highlight: Color.fromRGBO(40, 70, 100, 1),
|
||||
snippetText: Color.fromRGBO(193, 203, 213, 1),
|
||||
avatarPalette: [
|
||||
Color.fromRGBO(63, 207, 142, 1),
|
||||
Color.fromRGBO(90, 156, 240, 1),
|
||||
Color.fromRGBO(240, 176, 90, 1),
|
||||
Color.fromRGBO(214, 110, 140, 1),
|
||||
Color.fromRGBO(154, 133, 230, 1),
|
||||
Color.fromRGBO(90, 200, 210, 1),
|
||||
],
|
||||
);
|
||||
|
||||
static const light = AppColors(
|
||||
cardFill: Color.fromRGBO(236, 240, 243, 1),
|
||||
highlight: Color.fromRGBO(210, 231, 248, 1),
|
||||
snippetText: Color.fromRGBO(91, 100, 112, 1),
|
||||
avatarPalette: [
|
||||
Color.fromRGBO(43, 175, 116, 1),
|
||||
Color.fromRGBO(56, 116, 203, 1),
|
||||
Color.fromRGBO(196, 132, 41, 1),
|
||||
Color.fromRGBO(180, 76, 108, 1),
|
||||
Color.fromRGBO(115, 92, 199, 1),
|
||||
Color.fromRGBO(46, 152, 163, 1),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
AppColors copyWith({
|
||||
Color? cardFill,
|
||||
Color? highlight,
|
||||
Color? snippetText,
|
||||
List<Color>? avatarPalette,
|
||||
}) {
|
||||
return AppColors(
|
||||
cardFill: cardFill ?? this.cardFill,
|
||||
highlight: highlight ?? this.highlight,
|
||||
snippetText: snippetText ?? this.snippetText,
|
||||
avatarPalette: avatarPalette ?? this.avatarPalette,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AppColors lerp(ThemeExtension<AppColors>? other, double t) {
|
||||
if (other is! AppColors) return this;
|
||||
return AppColors(
|
||||
cardFill: Color.lerp(cardFill, other.cardFill, t)!,
|
||||
highlight: Color.lerp(highlight, other.highlight, t)!,
|
||||
snippetText: Color.lerp(snippetText, other.snippetText, t)!,
|
||||
avatarPalette: t < 0.5 ? avatarPalette : other.avatarPalette,
|
||||
);
|
||||
}
|
||||
}
|
||||
4
lib/presentation/theme/breakpoints.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/// Below this logical width the home shell shows a bottom [NavigationBar]
|
||||
/// (phone); at or above it, a side [NavigationRail] (desktop). Matches
|
||||
/// Material 3's compact/medium width class boundary.
|
||||
const double kDesktopBreakpoint = 600;
|
||||
|
|
@ -1,95 +1,143 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
final ThemeData appTheme = ThemeData(
|
||||
colorScheme: ColorScheme.fromSwatch().copyWith(
|
||||
primary: const Color.fromRGBO(63, 207, 142, 1),
|
||||
background: const Color.fromRGBO(1, 17, 29, 1),
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Color.fromRGBO(1, 17, 29, 1),
|
||||
iconTheme: IconThemeData(
|
||||
color: Colors.white,
|
||||
size: 30,
|
||||
),
|
||||
titleTextStyle: TextStyle(
|
||||
color: Colors.white,
|
||||
fontFamily: "Inter",
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 30,
|
||||
),
|
||||
elevation: 0.0,
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
bodySmall: TextStyle(
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
|
||||
const _seedColor = Color.fromRGBO(63, 207, 142, 1);
|
||||
const _darkSurface = Color.fromRGBO(1, 17, 29, 1);
|
||||
|
||||
TextTheme _textTheme(TextTheme base, Color mutedColor) {
|
||||
return base.copyWith(
|
||||
bodySmall: base.bodySmall?.copyWith(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
bodyMedium: base.bodyMedium?.copyWith(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 20,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
bodyLarge: TextStyle(
|
||||
bodyLarge: base.bodyLarge?.copyWith(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 24,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
color: Colors.white,
|
||||
titleMedium: base.titleMedium?.copyWith(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
titleLarge: TextStyle(
|
||||
titleLarge: base.titleLarge?.copyWith(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 35,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
labelSmall: TextStyle(
|
||||
labelSmall: base.labelSmall?.copyWith(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 16,
|
||||
color: Color.fromRGBO(127, 133, 140, 1),
|
||||
color: mutedColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
backgroundColor: Colors.red,
|
||||
contentTextStyle: TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color.fromRGBO(63, 207, 142, 1),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
);
|
||||
}
|
||||
|
||||
ThemeData _buildTheme(ColorScheme scheme, AppColors appColors) {
|
||||
return ThemeData(
|
||||
colorScheme: scheme,
|
||||
// ThemeData defaults this legacy field to Colors.grey[900] on a dark
|
||||
// theme when unset, which is nearly invisible against our dark surfaces —
|
||||
// several screens still read it via Theme.of(context).primaryColor.
|
||||
primaryColor: scheme.primary,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
extensions: [appColors],
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: scheme.surface,
|
||||
iconTheme: IconThemeData(color: scheme.onSurface, size: 30),
|
||||
titleTextStyle: TextStyle(
|
||||
color: scheme.onSurface,
|
||||
fontFamily: "Inter",
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 30,
|
||||
),
|
||||
elevation: 0.0,
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: const Color.fromRGBO(63, 207, 412, 1),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
textTheme: _textTheme(
|
||||
ThemeData(brightness: scheme.brightness).textTheme,
|
||||
scheme.onSurfaceVariant,
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: scheme.inverseSurface,
|
||||
contentTextStyle: TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 17,
|
||||
fontSize: 16,
|
||||
color: scheme.onInverseSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
useMaterial3: true,
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: scheme.primary,
|
||||
foregroundColor: scheme.onPrimary,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: scheme.primary,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 17,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Without this, an empty/unfocused field's resting label inherits
|
||||
// bodyLarge (24px bold) — that's what made "Host" etc. look huge.
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
labelStyle: TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
floatingLabelStyle: TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: scheme.primary,
|
||||
),
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
useMaterial3: true,
|
||||
);
|
||||
}
|
||||
|
||||
final ThemeData appThemeDark = _buildTheme(
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: _seedColor,
|
||||
brightness: Brightness.dark,
|
||||
).copyWith(surface: _darkSurface),
|
||||
AppColors.dark,
|
||||
);
|
||||
|
||||
final ThemeData appThemeLight = _buildTheme(
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: _seedColor,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
AppColors.light,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
import "package:external_path/external_path.dart";
|
||||
import "package:flash/flash.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_downloader/flutter_downloader.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flash_mail/presentation/widgets/drawer/toast_message.dart";
|
||||
|
||||
class AttachmentButton extends ConsumerWidget {
|
||||
final Map<String, dynamic> file;
|
||||
|
||||
const AttachmentButton({super.key, required this.file});
|
||||
|
||||
Future<void> downloadFile(BuildContext ctx, WidgetRef ref, String url) async {
|
||||
final token = ref.read(tokenProvider);
|
||||
showFlash(
|
||||
context: ctx,
|
||||
duration: const Duration(seconds: 2),
|
||||
builder: (context, controller) => ToastMessage(
|
||||
controller: controller,
|
||||
icon: Icons.arrow_downward,
|
||||
text: "Starting download!",
|
||||
),
|
||||
);
|
||||
|
||||
await FlutterDownloader.enqueue(
|
||||
headers: {"Authorization": "Bearer $token"},
|
||||
url: "https://api.mail.tm$url",
|
||||
savedDir: await ExternalPath.getExternalStoragePublicDirectory(
|
||||
ExternalPath.DIRECTORY_DOWNLOADS,
|
||||
),
|
||||
saveInPublicStorage: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: const Color.fromRGBO(137, 147, 164, 1),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.file_open_rounded,
|
||||
color: Colors.red,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
file["filename"],
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
splashColor: const Color.fromRGBO(40, 70, 100, 1),
|
||||
onPressed: () =>
|
||||
downloadFile(context, ref, file["downloadUrl"]),
|
||||
icon: const Icon(
|
||||
Icons.arrow_downward,
|
||||
size: 24,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
import "package:flash_mail/presentation/widgets/attachment/attachment_button.dart";
|
||||
|
||||
class AttachmentList extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> attachments;
|
||||
|
||||
const AttachmentList({super.key, required this.attachments});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: attachments.length,
|
||||
itemBuilder: (ctx, i) => AttachmentButton(file: attachments[i]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:dio/dio.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flash_mail/presentation/widgets/primary_button.dart";
|
||||
import "package:flash_mail/presentation/widgets/secondary_button.dart";
|
||||
import "package:flash_mail/presentation/widgets/small_loading_spinner.dart";
|
||||
|
||||
class AccountButtons extends StatelessWidget {
|
||||
const AccountButtons({super.key});
|
||||
|
||||
void showErrorSnackBar(BuildContext ctx, String message) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSubmit(BuildContext context, WidgetRef ref) async {
|
||||
try {
|
||||
await ref.read(accountProvider.notifier).createAccount();
|
||||
if (context.mounted) {
|
||||
AutoRouter.of(context).replaceNamed("/inbox");
|
||||
}
|
||||
} on DioError catch (err) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, err.message);
|
||||
}
|
||||
} catch (err) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, "Something went wrong!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
"Your Inbox, Your Privacy, Zero Ads.",
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
return PrimaryButton(
|
||||
child: ref.watch(accountProvider).isLoading
|
||||
? const SmallLoadingSpinner()
|
||||
: const Text("Create New Account"),
|
||||
onPressed: () => _handleSubmit(context, ref),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
SecondaryButton(
|
||||
text: "Use Existing",
|
||||
onPressed: () => AutoRouter.of(context).pushNamed("/login"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
class InputField extends StatelessWidget {
|
||||
final String label;
|
||||
final String placeholder;
|
||||
final TextInputAction inputAction;
|
||||
final String? Function(String?) validator;
|
||||
final void Function(String?) onSaved;
|
||||
final TextInputType? inputType;
|
||||
final bool? obscureText;
|
||||
|
||||
const InputField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.placeholder,
|
||||
required this.inputAction,
|
||||
required this.validator,
|
||||
required this.onSaved,
|
||||
this.inputType,
|
||||
this.obscureText,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextFormField(
|
||||
keyboardType: inputType,
|
||||
textInputAction: inputAction,
|
||||
obscureText: obscureText ?? false,
|
||||
validator: validator,
|
||||
onSaved: onSaved,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(fontSize: 18),
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: const Color.fromRGBO(22, 39, 56, 1),
|
||||
hintText: placeholder,
|
||||
hintStyle: const TextStyle(
|
||||
color: Color.fromRGBO(127, 133, 141, 1),
|
||||
),
|
||||
errorStyle: const TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:dio/dio.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flash_mail/presentation/widgets/auth/input_field.dart";
|
||||
import "package:flash_mail/presentation/widgets/primary_button.dart";
|
||||
import "package:flash_mail/presentation/widgets/small_loading_spinner.dart";
|
||||
import "package:flash_mail/shared/utils/regex_patterns.dart";
|
||||
|
||||
class LoginForm extends ConsumerWidget {
|
||||
final GlobalKey<FormState> _formKey = GlobalKey();
|
||||
|
||||
LoginForm({super.key});
|
||||
|
||||
String? _validateEmail(String? value) {
|
||||
if (value!.isEmpty || !emailRegex.hasMatch(value)) {
|
||||
return "Invalid email address";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validatePassword(String? value) {
|
||||
if (value!.isEmpty || value.length < 8) {
|
||||
return "Password too short";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void showErrorSnackBar(BuildContext ctx, String message) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSubmit(BuildContext context, WidgetRef ref) async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
FocusScope.of(context).unfocus();
|
||||
_formKey.currentState!.save();
|
||||
|
||||
try {
|
||||
String? email = ref.read(emailProvider.notifier).state;
|
||||
String? password = ref.read(passwordProvider.notifier).state;
|
||||
|
||||
await ref.read(accountProvider.notifier).login(email, password);
|
||||
|
||||
if (context.mounted) {
|
||||
AutoRouter.of(context).replaceNamed("/inbox");
|
||||
}
|
||||
} on DioError catch (err) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, err.message);
|
||||
}
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, "Something went wrong!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final account = ref.watch(accountProvider);
|
||||
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
InputField(
|
||||
label: "Email Address",
|
||||
placeholder: "Enter email",
|
||||
inputType: TextInputType.emailAddress,
|
||||
inputAction: TextInputAction.next,
|
||||
validator: _validateEmail,
|
||||
onSaved: (value) => ref.read(emailProvider.notifier).state = value,
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
InputField(
|
||||
label: "Password",
|
||||
placeholder: "Enter password",
|
||||
obscureText: true,
|
||||
inputAction: TextInputAction.done,
|
||||
validator: _validatePassword,
|
||||
onSaved: (value) =>
|
||||
ref.read(passwordProvider.notifier).state = value,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Center(
|
||||
child: PrimaryButton(
|
||||
onPressed: () => _handleSubmit(context, ref),
|
||||
child: account.isLoading
|
||||
? const SmallLoadingSpinner()
|
||||
: const Text("Login"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flash_mail/presentation/widgets/drawer/user_profile.dart";
|
||||
import "package:flash_mail/presentation/widgets/drawer/drawer_list.dart";
|
||||
import "package:flash_mail/presentation/widgets/drawer/user_quota.dart";
|
||||
|
||||
class AppDrawer extends ConsumerWidget {
|
||||
const AppDrawer({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final account = ref.read(accountProvider);
|
||||
|
||||
return Drawer(
|
||||
backgroundColor: const Color.fromRGBO(22, 39, 56, 1),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 90),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
UserProfile(
|
||||
email: account.user!.email,
|
||||
password: account.user!.password,
|
||||
),
|
||||
const SizedBox(height: 50),
|
||||
const DrawerList(),
|
||||
UserQuota(
|
||||
value: account.user!.used / account.user!.quota,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:auto_route/auto_route.dart";
|
||||
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flash_mail/shared/configs/flash_mail_icons.dart";
|
||||
import "package:flash_mail/presentation/widgets/drawer/drawer_list_tile.dart";
|
||||
|
||||
class DrawerList extends ConsumerWidget {
|
||||
const DrawerList({super.key});
|
||||
|
||||
Future<void> signOut(BuildContext context, WidgetRef ref) async {
|
||||
Navigator.of(context).pop();
|
||||
await ref.read(accountProvider.notifier).logout();
|
||||
if (context.mounted) {
|
||||
AutoRouter.of(context).replaceNamed("/");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 25),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
DrawerListTile(
|
||||
color: Theme.of(context).primaryColor,
|
||||
icon: FlashMailIcons.inbox,
|
||||
title: "Inbox",
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
Consumer(
|
||||
builder: (context, ref, _) => DrawerListTile(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
icon: FlashMailIcons.sign_out,
|
||||
title: "Sign out",
|
||||
onTap: () => signOut(context, ref),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
class DrawerListTile extends StatelessWidget {
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final void Function() onTap;
|
||||
|
||||
const DrawerListTile({
|
||||
super.key,
|
||||
required this.color,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
splashColor: const Color.fromRGBO(40, 70, 100, 1),
|
||||
highlightColor: const Color.fromRGBO(22, 39, 56, 1),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: onTap,
|
||||
leading: Icon(icon, color: color, size: 18),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: "Inter",
|
||||
color: color,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flash/flash.dart";
|
||||
|
||||
class ToastMessage extends StatelessWidget {
|
||||
final FlashController controller;
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
const ToastMessage({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.icon,
|
||||
required this.text,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Flash(
|
||||
position: FlashPosition.bottom,
|
||||
controller: controller,
|
||||
child: AlertDialog(
|
||||
alignment: Alignment.bottomCenter,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
backgroundColor: const Color.fromRGBO(217, 217, 217, 1),
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: Colors.black,
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Text(
|
||||
text,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall!
|
||||
.copyWith(color: Colors.black),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||