Compare commits

...

23 commits
v1.0.0 ... main

Author SHA1 Message Date
randogoth
aa74e12ee7 feat: support smol:// QR sharing, deep links and in-dialog scanning 2026-10-01 21:06:14 +03:00
randogoth
7d2a147fec fix: scope server pins by port (fumi-core rev 1468f3c) 2026-09-30 17:14:47 +03:00
randogoth
996e879509 docs: README refactor 2026-09-29 21:35:02 +03:00
randogoth
3ffaf34ca4 docs: removed stale screenshots 2026-09-29 21:28:33 +03:00
randogoth
503c448983 feat: adopt the sparkle as the app icon and splash 2026-09-29 21:21:59 +03:00
randogoth
ef13b31dee fix: name a prod flavor so the real app stays buildable 2026-09-29 21:21:59 +03:00
randogoth
b2880e6546 docs: add license, Flutter, Mistral and fork badges to the README 2026-09-29 21:21:59 +03:00
04d52bffaa Merge pull request 'fumi-core' (#1) from fumi-core into main
Reviewed-on: #1
2026-09-29 18:16:38 +00:00
randogoth
fa0809f021 test: patrol flows for fetch cancel, rotation and unchanged-key refresh 2026-09-29 21:00:42 +03:00
randogoth
f7452832ea fix: copy the master at each layer so zeroization stays local 2026-09-29 21:00:42 +03:00
randogoth
d7179694a6 fix: serialize contact history entries as objects across the FFI 2026-09-29 21:00:37 +03:00
randogoth
aa8c7ec71a feat: add AI-DECLARATION.md and MIT license badge
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-29 20:35:15 +03:00
randogoth
6f6fa20b58 feat: patrol on-device integration smoke test 2026-09-29 18:19:50 +03:00
randogoth
d6f4c434fa fix: read back sent copies after the sec 5.6 upstream fix, pinned at 2d65d66 2026-09-29 18:19:50 +03:00
randogoth
52947e153d feat: adopt bug logo and scale onboarding header with screen width 2026-09-27 17:39:29 +03:00
randogoth
c693e6fcb9 feat: adopt smolmail protocol 1.1, adaptive nav shell, and server mail retention 2026-09-27 11:07:11 +03:00
randogoth
eaaa3f2ede fix: onboarding restore no longer traps on an abandoned identity or a missing pin 2026-09-27 00:09:38 +03:00
randogoth
7d3c8b423c feat: match gsmol's v2 encrypted export format; fix export on desktop 2026-09-27 00:09:29 +03:00
randogoth
f80315e7c4 fix: store Hive data in the app support directory, not ~/Documents 2026-09-27 00:09:18 +03:00
randogoth
f57336b7ea feat: add Linux desktop build support 2026-09-27 00:09:11 +03:00
randogoth
854f9ab31d docs: add copyright line for the smolmail-client derivative work 2026-09-26 22:51:35 +03:00
randogoth
0ba82370f1 docs: rewrite README as a brief overview crediting FlashMail origin 2026-09-26 22:51:29 +03:00
randogoth
e94e4158cc feat: replace mail protocol with smolmail and rebuild the UI 2026-09-26 22:45:38 +03:00
158 changed files with 10071 additions and 3591 deletions

11
.gitignore vendored
View file

@ -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

View file

@ -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
View 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
-

View file

@ -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

View file

@ -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.
[![AI-DECLARATION: copilot](https://img.shields.io/badge/䷼%20AI--DECLARATION-copilot-fee2e2?labelColor=fee2e2)](https://ai-declaration.md)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) ![Powered by Mistral](https://img.shields.io/badge/Powered_by-Mistral_AI-FA520F?logo=mistral-ai&logoColor=white) ![Flutter](https://img.shields.io/badge/Flutter-02569B?logo=flutter&logoColor=white) [![Forked from flash-mail](https://img.shields.io/badge/Forked_from-sarthakkimtani%2Fflash--mail-59636e?logo=github&logoColor=white)](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.

View file

@ -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
View file

@ -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

View file

@ -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"
}

View 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")
}

View file

@ -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);
}
}

View file

@ -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>

View file

@ -1,4 +1,4 @@
package com.example.flash_mail
package com.example.smol_mail
import io.flutter.embedding.android.FlutterActivity

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 5 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Before After
Before After

View file

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

Before After
Before After

View file

@ -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>

View file

@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,007 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Before After
Before After

View file

@ -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>

View file

@ -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>

View file

@ -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
View 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)
}

View file

@ -1,3 +1,2 @@
org.gradle.jvmargs=-Xmx1536M
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View file

@ -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
View 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
View 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
View 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
}

View 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
]);
});
}

View file

@ -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"

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
assets/images/sparkle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

39
devbox.json Normal file
View 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
View 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"
}
}
}

View 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);
});
}

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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,
};

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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(),
};

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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(),
};

View file

@ -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;
}
}
}

View file

@ -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();
});

View file

@ -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
View 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
View 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
View 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");
}

View file

@ -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,
),
);
}
}

View file

@ -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);
}
}
}

View file

@ -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)],
),
];
}

View file

@ -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();
},
);
}

View file

@ -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);
}
}
}

View 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);
}
}
}

View 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);
}
}
}

View file

@ -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()
],
),
);
}
}

View 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"),
),
),
],
),
),
);
}
}

View 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),
],
),
);
}

View 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,
),
),
);
}

View 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),
],
),
);
},
);
}
}

View 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"),
),
],
);
}
}

View file

@ -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),
],
),
);
}

View file

@ -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(),
],
),
),
);
}
}

View file

@ -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),
),
),
);

View 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),
],
);
}
}

View 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),
);
}
}

View 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,
);
}
}

View 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;

View file

@ -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,
);

View file

@ -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,
),
),
),
)
],
),
);
}
}

View file

@ -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]),
);
}
}

View file

@ -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"),
),
],
);
}
}

View file

@ -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,
),
),
),
],
);
}
}

View file

@ -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"),
),
),
],
),
);
}
}

View file

@ -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,
),
],
),
),
);
}
}

View file

@ -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),
),
),
],
),
);
}
}

View file

@ -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,
),
),
),
);
}
}

View file

@ -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),
),
],
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show more