feat: replace mail protocol with smolmail and rebuild the UI
This commit is contained in:
parent
cb24b68069
commit
e94e4158cc
93 changed files with 6387 additions and 3479 deletions
|
|
@ -1,123 +0,0 @@
|
|||
import "dart:convert";
|
||||
|
||||
import "package:dio/dio.dart";
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/data/models/account/account.dart";
|
||||
import "package:flash_mail/data/models/message/message.dart";
|
||||
import "package:flash_mail/data/models/message_detail/message_detail.dart";
|
||||
import "package:flash_mail/shared/utils/random_string.dart";
|
||||
|
||||
class ApiClient {
|
||||
final Dio dio;
|
||||
final Ref ref;
|
||||
|
||||
ApiClient(this.dio, this.ref);
|
||||
|
||||
Future<void> generateToken(String address, String password) async {
|
||||
final tokenResponse = await dio.post(
|
||||
"/token",
|
||||
data: jsonEncode({"address": address, "password": password}),
|
||||
);
|
||||
final Map<String, dynamic> tokenData =
|
||||
tokenResponse.data as Map<String, dynamic>;
|
||||
|
||||
ref.read(tokenProvider.notifier).state = tokenData["token"];
|
||||
}
|
||||
|
||||
Future<TMAccount> createAccount() async {
|
||||
final response = await dio.get("/domains");
|
||||
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
|
||||
final List<Map<String, dynamic>> domains = List.from(data["hydra:member"]);
|
||||
|
||||
final username = randomString(7);
|
||||
final domain = domains[0]["domain"];
|
||||
Map<String, String> payload = {
|
||||
"address": "$username@$domain",
|
||||
"password": randomString(10),
|
||||
};
|
||||
|
||||
final userResponse = await dio.post(
|
||||
"/accounts",
|
||||
data: jsonEncode(payload),
|
||||
);
|
||||
final Map<String, dynamic> info = userResponse.data as Map<String, dynamic>;
|
||||
payload["address"] = info["address"];
|
||||
|
||||
await generateToken(
|
||||
payload["address"] as String,
|
||||
payload["password"] as String,
|
||||
);
|
||||
|
||||
return TMAccount(
|
||||
id: info["id"],
|
||||
email: info["address"],
|
||||
password: payload["password"]!,
|
||||
quota: info["quota"],
|
||||
used: info["used"],
|
||||
);
|
||||
}
|
||||
|
||||
Future<TMAccount> login(String address, String password) async {
|
||||
final response = await dio.post(
|
||||
"/token",
|
||||
data: jsonEncode({"address": address, "password": password}),
|
||||
);
|
||||
final Map<String, dynamic> tokenData =
|
||||
response.data as Map<String, dynamic>;
|
||||
|
||||
ref.read(tokenProvider.notifier).state = tokenData["token"];
|
||||
final userId = tokenData["id"];
|
||||
|
||||
final options = Options(headers: {
|
||||
"Authorization": "Bearer ${tokenData["token"]}",
|
||||
});
|
||||
final userResponse = await dio.get("/accounts/$userId", options: options);
|
||||
final Map<String, dynamic> info = userResponse.data as Map<String, dynamic>;
|
||||
|
||||
return TMAccount(
|
||||
id: info["id"],
|
||||
email: address,
|
||||
password: password,
|
||||
quota: info["quota"],
|
||||
used: info["used"],
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<TMMessage>> fetchMessages(String email, String password) async {
|
||||
if (ref.read(tokenProvider) == null) {
|
||||
await generateToken(email, password);
|
||||
}
|
||||
|
||||
final token = ref.read(tokenProvider);
|
||||
final options = Options(headers: {"Authorization": "Bearer $token"});
|
||||
final response = await dio.get("/messages", options: options);
|
||||
|
||||
Map<String, dynamic> messageData = response.data as Map<String, dynamic>;
|
||||
final messages =
|
||||
List<Map<String, dynamic>>.from(messageData["hydra:member"]);
|
||||
|
||||
return messages.map((message) => TMMessage.fromJson(message)).toList();
|
||||
}
|
||||
|
||||
Future<TMMessageDetail> fetchMessageDetail(String id) async {
|
||||
if (ref.read(tokenProvider) == null) throw Exception("Token not found");
|
||||
|
||||
final token = ref.read(tokenProvider);
|
||||
final options = Options(headers: {"Authorization": "Bearer $token"});
|
||||
final response = await dio.get("/messages/$id", options: options);
|
||||
|
||||
Map<String, dynamic> messageData = response.data as Map<String, dynamic>;
|
||||
|
||||
return TMMessageDetail.fromJson(messageData);
|
||||
}
|
||||
|
||||
Future<void> deleteMessage(String id) async {
|
||||
if (ref.read(tokenProvider) == null) throw Exception("Token not found");
|
||||
|
||||
final token = ref.read(tokenProvider);
|
||||
final options = Options(headers: {"Authorization": "Bearer $token"});
|
||||
await dio.delete("/messages/$id", options: options);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
import "package:hive/hive.dart";
|
||||
|
||||
part "account.freezed.dart";
|
||||
part "account.g.dart";
|
||||
|
||||
@freezed
|
||||
class TMAccount with _$TMAccount {
|
||||
@HiveType(typeId: 0, adapterName: "AccountAdapter")
|
||||
factory TMAccount({
|
||||
@HiveField(0) required String id,
|
||||
@HiveField(1) required String email,
|
||||
@HiveField(2) required String password,
|
||||
@HiveField(3) required int quota,
|
||||
@HiveField(4) required int used,
|
||||
}) = _TMAccount;
|
||||
|
||||
factory TMAccount.fromJson(Map<String, dynamic> json) =>
|
||||
_$TMAccountFromJson(json);
|
||||
}
|
||||
|
|
@ -1,252 +0,0 @@
|
|||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'account.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
|
||||
|
||||
TMAccount _$TMAccountFromJson(Map<String, dynamic> json) {
|
||||
return _TMAccount.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$TMAccount {
|
||||
@HiveField(0)
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
@HiveField(1)
|
||||
String get email => throw _privateConstructorUsedError;
|
||||
@HiveField(2)
|
||||
String get password => throw _privateConstructorUsedError;
|
||||
@HiveField(3)
|
||||
int get quota => throw _privateConstructorUsedError;
|
||||
@HiveField(4)
|
||||
int get used => throw _privateConstructorUsedError;
|
||||
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@JsonKey(ignore: true)
|
||||
$TMAccountCopyWith<TMAccount> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $TMAccountCopyWith<$Res> {
|
||||
factory $TMAccountCopyWith(TMAccount value, $Res Function(TMAccount) then) =
|
||||
_$TMAccountCopyWithImpl<$Res, TMAccount>;
|
||||
@useResult
|
||||
$Res call(
|
||||
{@HiveField(0) String id,
|
||||
@HiveField(1) String email,
|
||||
@HiveField(2) String password,
|
||||
@HiveField(3) int quota,
|
||||
@HiveField(4) int used});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$TMAccountCopyWithImpl<$Res, $Val extends TMAccount>
|
||||
implements $TMAccountCopyWith<$Res> {
|
||||
_$TMAccountCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? email = null,
|
||||
Object? password = null,
|
||||
Object? quota = null,
|
||||
Object? used = null,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
email: null == email
|
||||
? _value.email
|
||||
: email // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
password: null == password
|
||||
? _value.password
|
||||
: password // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
quota: null == quota
|
||||
? _value.quota
|
||||
: quota // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
used: null == used
|
||||
? _value.used
|
||||
: used // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$TMAccountImplCopyWith<$Res>
|
||||
implements $TMAccountCopyWith<$Res> {
|
||||
factory _$$TMAccountImplCopyWith(
|
||||
_$TMAccountImpl value, $Res Function(_$TMAccountImpl) then) =
|
||||
__$$TMAccountImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{@HiveField(0) String id,
|
||||
@HiveField(1) String email,
|
||||
@HiveField(2) String password,
|
||||
@HiveField(3) int quota,
|
||||
@HiveField(4) int used});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$TMAccountImplCopyWithImpl<$Res>
|
||||
extends _$TMAccountCopyWithImpl<$Res, _$TMAccountImpl>
|
||||
implements _$$TMAccountImplCopyWith<$Res> {
|
||||
__$$TMAccountImplCopyWithImpl(
|
||||
_$TMAccountImpl _value, $Res Function(_$TMAccountImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? email = null,
|
||||
Object? password = null,
|
||||
Object? quota = null,
|
||||
Object? used = null,
|
||||
}) {
|
||||
return _then(_$TMAccountImpl(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
email: null == email
|
||||
? _value.email
|
||||
: email // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
password: null == password
|
||||
? _value.password
|
||||
: password // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
quota: null == quota
|
||||
? _value.quota
|
||||
: quota // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
used: null == used
|
||||
? _value.used
|
||||
: used // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
@HiveType(typeId: 0, adapterName: "AccountAdapter")
|
||||
class _$TMAccountImpl implements _TMAccount {
|
||||
_$TMAccountImpl(
|
||||
{@HiveField(0) required this.id,
|
||||
@HiveField(1) required this.email,
|
||||
@HiveField(2) required this.password,
|
||||
@HiveField(3) required this.quota,
|
||||
@HiveField(4) required this.used});
|
||||
|
||||
factory _$TMAccountImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$TMAccountImplFromJson(json);
|
||||
|
||||
@override
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
@override
|
||||
@HiveField(1)
|
||||
final String email;
|
||||
@override
|
||||
@HiveField(2)
|
||||
final String password;
|
||||
@override
|
||||
@HiveField(3)
|
||||
final int quota;
|
||||
@override
|
||||
@HiveField(4)
|
||||
final int used;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'TMAccount(id: $id, email: $email, password: $password, quota: $quota, used: $used)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(dynamic other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$TMAccountImpl &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.email, email) || other.email == email) &&
|
||||
(identical(other.password, password) ||
|
||||
other.password == password) &&
|
||||
(identical(other.quota, quota) || other.quota == quota) &&
|
||||
(identical(other.used, used) || other.used == used));
|
||||
}
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(runtimeType, id, email, password, quota, used);
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$TMAccountImplCopyWith<_$TMAccountImpl> get copyWith =>
|
||||
__$$TMAccountImplCopyWithImpl<_$TMAccountImpl>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$TMAccountImplToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _TMAccount implements TMAccount {
|
||||
factory _TMAccount(
|
||||
{@HiveField(0) required final String id,
|
||||
@HiveField(1) required final String email,
|
||||
@HiveField(2) required final String password,
|
||||
@HiveField(3) required final int quota,
|
||||
@HiveField(4) required final int used}) = _$TMAccountImpl;
|
||||
|
||||
factory _TMAccount.fromJson(Map<String, dynamic> json) =
|
||||
_$TMAccountImpl.fromJson;
|
||||
|
||||
@override
|
||||
@HiveField(0)
|
||||
String get id;
|
||||
@override
|
||||
@HiveField(1)
|
||||
String get email;
|
||||
@override
|
||||
@HiveField(2)
|
||||
String get password;
|
||||
@override
|
||||
@HiveField(3)
|
||||
int get quota;
|
||||
@override
|
||||
@HiveField(4)
|
||||
int get used;
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
_$$TMAccountImplCopyWith<_$TMAccountImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'account.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class AccountAdapter extends TypeAdapter<_$TMAccountImpl> {
|
||||
@override
|
||||
final int typeId = 0;
|
||||
|
||||
@override
|
||||
_$TMAccountImpl read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return _$TMAccountImpl(
|
||||
id: fields[0] as String,
|
||||
email: fields[1] as String,
|
||||
password: fields[2] as String,
|
||||
quota: fields[3] as int,
|
||||
used: fields[4] as int,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, _$TMAccountImpl obj) {
|
||||
writer
|
||||
..writeByte(5)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.email)
|
||||
..writeByte(2)
|
||||
..write(obj.password)
|
||||
..writeByte(3)
|
||||
..write(obj.quota)
|
||||
..writeByte(4)
|
||||
..write(obj.used);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AccountAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$TMAccountImpl _$$TMAccountImplFromJson(Map<String, dynamic> json) =>
|
||||
_$TMAccountImpl(
|
||||
id: json['id'] as String,
|
||||
email: json['email'] as String,
|
||||
password: json['password'] as String,
|
||||
quota: json['quota'] as int,
|
||||
used: json['used'] as int,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$TMAccountImplToJson(_$TMAccountImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'email': instance.email,
|
||||
'password': instance.password,
|
||||
'quota': instance.quota,
|
||||
'used': instance.used,
|
||||
};
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
|
||||
part "message.freezed.dart";
|
||||
part "message.g.dart";
|
||||
|
||||
@freezed
|
||||
class TMMessage with _$TMMessage {
|
||||
factory TMMessage({
|
||||
required String id,
|
||||
required Map<String, String> from,
|
||||
required List<Map<String, String>> to,
|
||||
required String? intro,
|
||||
required String subject,
|
||||
required bool seen,
|
||||
required bool isDeleted,
|
||||
required bool hasAttachments,
|
||||
required String downloadUrl,
|
||||
required DateTime createdAt,
|
||||
}) = _TMMessage;
|
||||
|
||||
factory TMMessage.fromJson(Map<String, dynamic> json) =>
|
||||
_$TMMessageFromJson(json);
|
||||
}
|
||||
|
|
@ -1,363 +0,0 @@
|
|||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'message.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
|
||||
|
||||
TMMessage _$TMMessageFromJson(Map<String, dynamic> json) {
|
||||
return _TMMessage.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$TMMessage {
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
Map<String, String> get from => throw _privateConstructorUsedError;
|
||||
List<Map<String, String>> get to => throw _privateConstructorUsedError;
|
||||
String? get intro => throw _privateConstructorUsedError;
|
||||
String get subject => throw _privateConstructorUsedError;
|
||||
bool get seen => throw _privateConstructorUsedError;
|
||||
bool get isDeleted => throw _privateConstructorUsedError;
|
||||
bool get hasAttachments => throw _privateConstructorUsedError;
|
||||
String get downloadUrl => throw _privateConstructorUsedError;
|
||||
DateTime get createdAt => throw _privateConstructorUsedError;
|
||||
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@JsonKey(ignore: true)
|
||||
$TMMessageCopyWith<TMMessage> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $TMMessageCopyWith<$Res> {
|
||||
factory $TMMessageCopyWith(TMMessage value, $Res Function(TMMessage) then) =
|
||||
_$TMMessageCopyWithImpl<$Res, TMMessage>;
|
||||
@useResult
|
||||
$Res call(
|
||||
{String id,
|
||||
Map<String, String> from,
|
||||
List<Map<String, String>> to,
|
||||
String? intro,
|
||||
String subject,
|
||||
bool seen,
|
||||
bool isDeleted,
|
||||
bool hasAttachments,
|
||||
String downloadUrl,
|
||||
DateTime createdAt});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$TMMessageCopyWithImpl<$Res, $Val extends TMMessage>
|
||||
implements $TMMessageCopyWith<$Res> {
|
||||
_$TMMessageCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? from = null,
|
||||
Object? to = null,
|
||||
Object? intro = freezed,
|
||||
Object? subject = null,
|
||||
Object? seen = null,
|
||||
Object? isDeleted = null,
|
||||
Object? hasAttachments = null,
|
||||
Object? downloadUrl = null,
|
||||
Object? createdAt = null,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
from: null == from
|
||||
? _value.from
|
||||
: from // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, String>,
|
||||
to: null == to
|
||||
? _value.to
|
||||
: to // ignore: cast_nullable_to_non_nullable
|
||||
as List<Map<String, String>>,
|
||||
intro: freezed == intro
|
||||
? _value.intro
|
||||
: intro // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
subject: null == subject
|
||||
? _value.subject
|
||||
: subject // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
seen: null == seen
|
||||
? _value.seen
|
||||
: seen // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
isDeleted: null == isDeleted
|
||||
? _value.isDeleted
|
||||
: isDeleted // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
hasAttachments: null == hasAttachments
|
||||
? _value.hasAttachments
|
||||
: hasAttachments // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
downloadUrl: null == downloadUrl
|
||||
? _value.downloadUrl
|
||||
: downloadUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$TMMessageImplCopyWith<$Res>
|
||||
implements $TMMessageCopyWith<$Res> {
|
||||
factory _$$TMMessageImplCopyWith(
|
||||
_$TMMessageImpl value, $Res Function(_$TMMessageImpl) then) =
|
||||
__$$TMMessageImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{String id,
|
||||
Map<String, String> from,
|
||||
List<Map<String, String>> to,
|
||||
String? intro,
|
||||
String subject,
|
||||
bool seen,
|
||||
bool isDeleted,
|
||||
bool hasAttachments,
|
||||
String downloadUrl,
|
||||
DateTime createdAt});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$TMMessageImplCopyWithImpl<$Res>
|
||||
extends _$TMMessageCopyWithImpl<$Res, _$TMMessageImpl>
|
||||
implements _$$TMMessageImplCopyWith<$Res> {
|
||||
__$$TMMessageImplCopyWithImpl(
|
||||
_$TMMessageImpl _value, $Res Function(_$TMMessageImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? from = null,
|
||||
Object? to = null,
|
||||
Object? intro = freezed,
|
||||
Object? subject = null,
|
||||
Object? seen = null,
|
||||
Object? isDeleted = null,
|
||||
Object? hasAttachments = null,
|
||||
Object? downloadUrl = null,
|
||||
Object? createdAt = null,
|
||||
}) {
|
||||
return _then(_$TMMessageImpl(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
from: null == from
|
||||
? _value._from
|
||||
: from // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, String>,
|
||||
to: null == to
|
||||
? _value._to
|
||||
: to // ignore: cast_nullable_to_non_nullable
|
||||
as List<Map<String, String>>,
|
||||
intro: freezed == intro
|
||||
? _value.intro
|
||||
: intro // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
subject: null == subject
|
||||
? _value.subject
|
||||
: subject // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
seen: null == seen
|
||||
? _value.seen
|
||||
: seen // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
isDeleted: null == isDeleted
|
||||
? _value.isDeleted
|
||||
: isDeleted // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
hasAttachments: null == hasAttachments
|
||||
? _value.hasAttachments
|
||||
: hasAttachments // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
downloadUrl: null == downloadUrl
|
||||
? _value.downloadUrl
|
||||
: downloadUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$TMMessageImpl implements _TMMessage {
|
||||
_$TMMessageImpl(
|
||||
{required this.id,
|
||||
required final Map<String, String> from,
|
||||
required final List<Map<String, String>> to,
|
||||
required this.intro,
|
||||
required this.subject,
|
||||
required this.seen,
|
||||
required this.isDeleted,
|
||||
required this.hasAttachments,
|
||||
required this.downloadUrl,
|
||||
required this.createdAt})
|
||||
: _from = from,
|
||||
_to = to;
|
||||
|
||||
factory _$TMMessageImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$TMMessageImplFromJson(json);
|
||||
|
||||
@override
|
||||
final String id;
|
||||
final Map<String, String> _from;
|
||||
@override
|
||||
Map<String, String> get from {
|
||||
if (_from is EqualUnmodifiableMapView) return _from;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_from);
|
||||
}
|
||||
|
||||
final List<Map<String, String>> _to;
|
||||
@override
|
||||
List<Map<String, String>> get to {
|
||||
if (_to is EqualUnmodifiableListView) return _to;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_to);
|
||||
}
|
||||
|
||||
@override
|
||||
final String? intro;
|
||||
@override
|
||||
final String subject;
|
||||
@override
|
||||
final bool seen;
|
||||
@override
|
||||
final bool isDeleted;
|
||||
@override
|
||||
final bool hasAttachments;
|
||||
@override
|
||||
final String downloadUrl;
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'TMMessage(id: $id, from: $from, to: $to, intro: $intro, subject: $subject, seen: $seen, isDeleted: $isDeleted, hasAttachments: $hasAttachments, downloadUrl: $downloadUrl, createdAt: $createdAt)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(dynamic other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$TMMessageImpl &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
const DeepCollectionEquality().equals(other._from, _from) &&
|
||||
const DeepCollectionEquality().equals(other._to, _to) &&
|
||||
(identical(other.intro, intro) || other.intro == intro) &&
|
||||
(identical(other.subject, subject) || other.subject == subject) &&
|
||||
(identical(other.seen, seen) || other.seen == seen) &&
|
||||
(identical(other.isDeleted, isDeleted) ||
|
||||
other.isDeleted == isDeleted) &&
|
||||
(identical(other.hasAttachments, hasAttachments) ||
|
||||
other.hasAttachments == hasAttachments) &&
|
||||
(identical(other.downloadUrl, downloadUrl) ||
|
||||
other.downloadUrl == downloadUrl) &&
|
||||
(identical(other.createdAt, createdAt) ||
|
||||
other.createdAt == createdAt));
|
||||
}
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
id,
|
||||
const DeepCollectionEquality().hash(_from),
|
||||
const DeepCollectionEquality().hash(_to),
|
||||
intro,
|
||||
subject,
|
||||
seen,
|
||||
isDeleted,
|
||||
hasAttachments,
|
||||
downloadUrl,
|
||||
createdAt);
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$TMMessageImplCopyWith<_$TMMessageImpl> get copyWith =>
|
||||
__$$TMMessageImplCopyWithImpl<_$TMMessageImpl>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$TMMessageImplToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _TMMessage implements TMMessage {
|
||||
factory _TMMessage(
|
||||
{required final String id,
|
||||
required final Map<String, String> from,
|
||||
required final List<Map<String, String>> to,
|
||||
required final String? intro,
|
||||
required final String subject,
|
||||
required final bool seen,
|
||||
required final bool isDeleted,
|
||||
required final bool hasAttachments,
|
||||
required final String downloadUrl,
|
||||
required final DateTime createdAt}) = _$TMMessageImpl;
|
||||
|
||||
factory _TMMessage.fromJson(Map<String, dynamic> json) =
|
||||
_$TMMessageImpl.fromJson;
|
||||
|
||||
@override
|
||||
String get id;
|
||||
@override
|
||||
Map<String, String> get from;
|
||||
@override
|
||||
List<Map<String, String>> get to;
|
||||
@override
|
||||
String? get intro;
|
||||
@override
|
||||
String get subject;
|
||||
@override
|
||||
bool get seen;
|
||||
@override
|
||||
bool get isDeleted;
|
||||
@override
|
||||
bool get hasAttachments;
|
||||
@override
|
||||
String get downloadUrl;
|
||||
@override
|
||||
DateTime get createdAt;
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
_$$TMMessageImplCopyWith<_$TMMessageImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'message.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$TMMessageImpl _$$TMMessageImplFromJson(Map<String, dynamic> json) =>
|
||||
_$TMMessageImpl(
|
||||
id: json['id'] as String,
|
||||
from: Map<String, String>.from(json['from'] as Map),
|
||||
to: (json['to'] as List<dynamic>)
|
||||
.map((e) => Map<String, String>.from(e as Map))
|
||||
.toList(),
|
||||
intro: json['intro'] as String?,
|
||||
subject: json['subject'] as String,
|
||||
seen: json['seen'] as bool,
|
||||
isDeleted: json['isDeleted'] as bool,
|
||||
hasAttachments: json['hasAttachments'] as bool,
|
||||
downloadUrl: json['downloadUrl'] as String,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$TMMessageImplToJson(_$TMMessageImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'from': instance.from,
|
||||
'to': instance.to,
|
||||
'intro': instance.intro,
|
||||
'subject': instance.subject,
|
||||
'seen': instance.seen,
|
||||
'isDeleted': instance.isDeleted,
|
||||
'hasAttachments': instance.hasAttachments,
|
||||
'downloadUrl': instance.downloadUrl,
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
};
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import "package:freezed_annotation/freezed_annotation.dart";
|
||||
|
||||
part "message_detail.freezed.dart";
|
||||
part "message_detail.g.dart";
|
||||
|
||||
@freezed
|
||||
class TMMessageDetail with _$TMMessageDetail {
|
||||
factory TMMessageDetail({
|
||||
required String id,
|
||||
required Map<String, String> from,
|
||||
required List<Map<String, String>> to,
|
||||
required List<String> cc,
|
||||
required List<String> bcc,
|
||||
required String subject,
|
||||
required String? text,
|
||||
required List<String> html,
|
||||
required bool hasAttachments,
|
||||
required List<Map<String, dynamic>>? attachments,
|
||||
required String downloadUrl,
|
||||
required DateTime createdAt,
|
||||
}) = _TMMessageDetail;
|
||||
|
||||
factory TMMessageDetail.fromJson(Map<String, dynamic> json) =>
|
||||
_$TMMessageDetailFromJson(json);
|
||||
}
|
||||
|
|
@ -1,438 +0,0 @@
|
|||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'message_detail.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
|
||||
|
||||
TMMessageDetail _$TMMessageDetailFromJson(Map<String, dynamic> json) {
|
||||
return _TMMessageDetail.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$TMMessageDetail {
|
||||
String get id => throw _privateConstructorUsedError;
|
||||
Map<String, String> get from => throw _privateConstructorUsedError;
|
||||
List<Map<String, String>> get to => throw _privateConstructorUsedError;
|
||||
List<String> get cc => throw _privateConstructorUsedError;
|
||||
List<String> get bcc => throw _privateConstructorUsedError;
|
||||
String get subject => throw _privateConstructorUsedError;
|
||||
String? get text => throw _privateConstructorUsedError;
|
||||
List<String> get html => throw _privateConstructorUsedError;
|
||||
bool get hasAttachments => throw _privateConstructorUsedError;
|
||||
List<Map<String, dynamic>>? get attachments =>
|
||||
throw _privateConstructorUsedError;
|
||||
String get downloadUrl => throw _privateConstructorUsedError;
|
||||
DateTime get createdAt => throw _privateConstructorUsedError;
|
||||
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@JsonKey(ignore: true)
|
||||
$TMMessageDetailCopyWith<TMMessageDetail> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $TMMessageDetailCopyWith<$Res> {
|
||||
factory $TMMessageDetailCopyWith(
|
||||
TMMessageDetail value, $Res Function(TMMessageDetail) then) =
|
||||
_$TMMessageDetailCopyWithImpl<$Res, TMMessageDetail>;
|
||||
@useResult
|
||||
$Res call(
|
||||
{String id,
|
||||
Map<String, String> from,
|
||||
List<Map<String, String>> to,
|
||||
List<String> cc,
|
||||
List<String> bcc,
|
||||
String subject,
|
||||
String? text,
|
||||
List<String> html,
|
||||
bool hasAttachments,
|
||||
List<Map<String, dynamic>>? attachments,
|
||||
String downloadUrl,
|
||||
DateTime createdAt});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$TMMessageDetailCopyWithImpl<$Res, $Val extends TMMessageDetail>
|
||||
implements $TMMessageDetailCopyWith<$Res> {
|
||||
_$TMMessageDetailCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? from = null,
|
||||
Object? to = null,
|
||||
Object? cc = null,
|
||||
Object? bcc = null,
|
||||
Object? subject = null,
|
||||
Object? text = freezed,
|
||||
Object? html = null,
|
||||
Object? hasAttachments = null,
|
||||
Object? attachments = freezed,
|
||||
Object? downloadUrl = null,
|
||||
Object? createdAt = null,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
from: null == from
|
||||
? _value.from
|
||||
: from // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, String>,
|
||||
to: null == to
|
||||
? _value.to
|
||||
: to // ignore: cast_nullable_to_non_nullable
|
||||
as List<Map<String, String>>,
|
||||
cc: null == cc
|
||||
? _value.cc
|
||||
: cc // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
bcc: null == bcc
|
||||
? _value.bcc
|
||||
: bcc // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
subject: null == subject
|
||||
? _value.subject
|
||||
: subject // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
text: freezed == text
|
||||
? _value.text
|
||||
: text // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
html: null == html
|
||||
? _value.html
|
||||
: html // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
hasAttachments: null == hasAttachments
|
||||
? _value.hasAttachments
|
||||
: hasAttachments // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
attachments: freezed == attachments
|
||||
? _value.attachments
|
||||
: attachments // ignore: cast_nullable_to_non_nullable
|
||||
as List<Map<String, dynamic>>?,
|
||||
downloadUrl: null == downloadUrl
|
||||
? _value.downloadUrl
|
||||
: downloadUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$TMMessageDetailImplCopyWith<$Res>
|
||||
implements $TMMessageDetailCopyWith<$Res> {
|
||||
factory _$$TMMessageDetailImplCopyWith(_$TMMessageDetailImpl value,
|
||||
$Res Function(_$TMMessageDetailImpl) then) =
|
||||
__$$TMMessageDetailImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{String id,
|
||||
Map<String, String> from,
|
||||
List<Map<String, String>> to,
|
||||
List<String> cc,
|
||||
List<String> bcc,
|
||||
String subject,
|
||||
String? text,
|
||||
List<String> html,
|
||||
bool hasAttachments,
|
||||
List<Map<String, dynamic>>? attachments,
|
||||
String downloadUrl,
|
||||
DateTime createdAt});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$TMMessageDetailImplCopyWithImpl<$Res>
|
||||
extends _$TMMessageDetailCopyWithImpl<$Res, _$TMMessageDetailImpl>
|
||||
implements _$$TMMessageDetailImplCopyWith<$Res> {
|
||||
__$$TMMessageDetailImplCopyWithImpl(
|
||||
_$TMMessageDetailImpl _value, $Res Function(_$TMMessageDetailImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? from = null,
|
||||
Object? to = null,
|
||||
Object? cc = null,
|
||||
Object? bcc = null,
|
||||
Object? subject = null,
|
||||
Object? text = freezed,
|
||||
Object? html = null,
|
||||
Object? hasAttachments = null,
|
||||
Object? attachments = freezed,
|
||||
Object? downloadUrl = null,
|
||||
Object? createdAt = null,
|
||||
}) {
|
||||
return _then(_$TMMessageDetailImpl(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
from: null == from
|
||||
? _value._from
|
||||
: from // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, String>,
|
||||
to: null == to
|
||||
? _value._to
|
||||
: to // ignore: cast_nullable_to_non_nullable
|
||||
as List<Map<String, String>>,
|
||||
cc: null == cc
|
||||
? _value._cc
|
||||
: cc // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
bcc: null == bcc
|
||||
? _value._bcc
|
||||
: bcc // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
subject: null == subject
|
||||
? _value.subject
|
||||
: subject // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
text: freezed == text
|
||||
? _value.text
|
||||
: text // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
html: null == html
|
||||
? _value._html
|
||||
: html // ignore: cast_nullable_to_non_nullable
|
||||
as List<String>,
|
||||
hasAttachments: null == hasAttachments
|
||||
? _value.hasAttachments
|
||||
: hasAttachments // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
attachments: freezed == attachments
|
||||
? _value._attachments
|
||||
: attachments // ignore: cast_nullable_to_non_nullable
|
||||
as List<Map<String, dynamic>>?,
|
||||
downloadUrl: null == downloadUrl
|
||||
? _value.downloadUrl
|
||||
: downloadUrl // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$TMMessageDetailImpl implements _TMMessageDetail {
|
||||
_$TMMessageDetailImpl(
|
||||
{required this.id,
|
||||
required final Map<String, String> from,
|
||||
required final List<Map<String, String>> to,
|
||||
required final List<String> cc,
|
||||
required final List<String> bcc,
|
||||
required this.subject,
|
||||
required this.text,
|
||||
required final List<String> html,
|
||||
required this.hasAttachments,
|
||||
required final List<Map<String, dynamic>>? attachments,
|
||||
required this.downloadUrl,
|
||||
required this.createdAt})
|
||||
: _from = from,
|
||||
_to = to,
|
||||
_cc = cc,
|
||||
_bcc = bcc,
|
||||
_html = html,
|
||||
_attachments = attachments;
|
||||
|
||||
factory _$TMMessageDetailImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$TMMessageDetailImplFromJson(json);
|
||||
|
||||
@override
|
||||
final String id;
|
||||
final Map<String, String> _from;
|
||||
@override
|
||||
Map<String, String> get from {
|
||||
if (_from is EqualUnmodifiableMapView) return _from;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(_from);
|
||||
}
|
||||
|
||||
final List<Map<String, String>> _to;
|
||||
@override
|
||||
List<Map<String, String>> get to {
|
||||
if (_to is EqualUnmodifiableListView) return _to;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_to);
|
||||
}
|
||||
|
||||
final List<String> _cc;
|
||||
@override
|
||||
List<String> get cc {
|
||||
if (_cc is EqualUnmodifiableListView) return _cc;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_cc);
|
||||
}
|
||||
|
||||
final List<String> _bcc;
|
||||
@override
|
||||
List<String> get bcc {
|
||||
if (_bcc is EqualUnmodifiableListView) return _bcc;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_bcc);
|
||||
}
|
||||
|
||||
@override
|
||||
final String subject;
|
||||
@override
|
||||
final String? text;
|
||||
final List<String> _html;
|
||||
@override
|
||||
List<String> get html {
|
||||
if (_html is EqualUnmodifiableListView) return _html;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_html);
|
||||
}
|
||||
|
||||
@override
|
||||
final bool hasAttachments;
|
||||
final List<Map<String, dynamic>>? _attachments;
|
||||
@override
|
||||
List<Map<String, dynamic>>? get attachments {
|
||||
final value = _attachments;
|
||||
if (value == null) return null;
|
||||
if (_attachments is EqualUnmodifiableListView) return _attachments;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(value);
|
||||
}
|
||||
|
||||
@override
|
||||
final String downloadUrl;
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'TMMessageDetail(id: $id, from: $from, to: $to, cc: $cc, bcc: $bcc, subject: $subject, text: $text, html: $html, hasAttachments: $hasAttachments, attachments: $attachments, downloadUrl: $downloadUrl, createdAt: $createdAt)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(dynamic other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$TMMessageDetailImpl &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
const DeepCollectionEquality().equals(other._from, _from) &&
|
||||
const DeepCollectionEquality().equals(other._to, _to) &&
|
||||
const DeepCollectionEquality().equals(other._cc, _cc) &&
|
||||
const DeepCollectionEquality().equals(other._bcc, _bcc) &&
|
||||
(identical(other.subject, subject) || other.subject == subject) &&
|
||||
(identical(other.text, text) || other.text == text) &&
|
||||
const DeepCollectionEquality().equals(other._html, _html) &&
|
||||
(identical(other.hasAttachments, hasAttachments) ||
|
||||
other.hasAttachments == hasAttachments) &&
|
||||
const DeepCollectionEquality()
|
||||
.equals(other._attachments, _attachments) &&
|
||||
(identical(other.downloadUrl, downloadUrl) ||
|
||||
other.downloadUrl == downloadUrl) &&
|
||||
(identical(other.createdAt, createdAt) ||
|
||||
other.createdAt == createdAt));
|
||||
}
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
id,
|
||||
const DeepCollectionEquality().hash(_from),
|
||||
const DeepCollectionEquality().hash(_to),
|
||||
const DeepCollectionEquality().hash(_cc),
|
||||
const DeepCollectionEquality().hash(_bcc),
|
||||
subject,
|
||||
text,
|
||||
const DeepCollectionEquality().hash(_html),
|
||||
hasAttachments,
|
||||
const DeepCollectionEquality().hash(_attachments),
|
||||
downloadUrl,
|
||||
createdAt);
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$TMMessageDetailImplCopyWith<_$TMMessageDetailImpl> get copyWith =>
|
||||
__$$TMMessageDetailImplCopyWithImpl<_$TMMessageDetailImpl>(
|
||||
this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$TMMessageDetailImplToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _TMMessageDetail implements TMMessageDetail {
|
||||
factory _TMMessageDetail(
|
||||
{required final String id,
|
||||
required final Map<String, String> from,
|
||||
required final List<Map<String, String>> to,
|
||||
required final List<String> cc,
|
||||
required final List<String> bcc,
|
||||
required final String subject,
|
||||
required final String? text,
|
||||
required final List<String> html,
|
||||
required final bool hasAttachments,
|
||||
required final List<Map<String, dynamic>>? attachments,
|
||||
required final String downloadUrl,
|
||||
required final DateTime createdAt}) = _$TMMessageDetailImpl;
|
||||
|
||||
factory _TMMessageDetail.fromJson(Map<String, dynamic> json) =
|
||||
_$TMMessageDetailImpl.fromJson;
|
||||
|
||||
@override
|
||||
String get id;
|
||||
@override
|
||||
Map<String, String> get from;
|
||||
@override
|
||||
List<Map<String, String>> get to;
|
||||
@override
|
||||
List<String> get cc;
|
||||
@override
|
||||
List<String> get bcc;
|
||||
@override
|
||||
String get subject;
|
||||
@override
|
||||
String? get text;
|
||||
@override
|
||||
List<String> get html;
|
||||
@override
|
||||
bool get hasAttachments;
|
||||
@override
|
||||
List<Map<String, dynamic>>? get attachments;
|
||||
@override
|
||||
String get downloadUrl;
|
||||
@override
|
||||
DateTime get createdAt;
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
_$$TMMessageDetailImplCopyWith<_$TMMessageDetailImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'message_detail.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$TMMessageDetailImpl _$$TMMessageDetailImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$TMMessageDetailImpl(
|
||||
id: json['id'] as String,
|
||||
from: Map<String, String>.from(json['from'] as Map),
|
||||
to: (json['to'] as List<dynamic>)
|
||||
.map((e) => Map<String, String>.from(e as Map))
|
||||
.toList(),
|
||||
cc: (json['cc'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
bcc: (json['bcc'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
subject: json['subject'] as String,
|
||||
text: json['text'] as String?,
|
||||
html: (json['html'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
hasAttachments: json['hasAttachments'] as bool,
|
||||
attachments: (json['attachments'] as List<dynamic>?)
|
||||
?.map((e) => e as Map<String, dynamic>)
|
||||
.toList(),
|
||||
downloadUrl: json['downloadUrl'] as String,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$TMMessageDetailImplToJson(
|
||||
_$TMMessageDetailImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'from': instance.from,
|
||||
'to': instance.to,
|
||||
'cc': instance.cc,
|
||||
'bcc': instance.bcc,
|
||||
'subject': instance.subject,
|
||||
'text': instance.text,
|
||||
'html': instance.html,
|
||||
'hasAttachments': instance.hasAttachments,
|
||||
'attachments': instance.attachments,
|
||||
'downloadUrl': instance.downloadUrl,
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
};
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:hive_flutter/adapters.dart";
|
||||
|
||||
import "package:flash_mail/data/models/account/account.dart";
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
|
||||
class AccountState {
|
||||
final bool isLoading;
|
||||
final TMAccount? user;
|
||||
|
||||
const AccountState({
|
||||
this.isLoading = false,
|
||||
this.user,
|
||||
});
|
||||
|
||||
bool isAuthenticated() {
|
||||
return user != null;
|
||||
}
|
||||
}
|
||||
|
||||
class AccountNotifier extends StateNotifier<AccountState> {
|
||||
static const _boxName = "accounts";
|
||||
static const _key = "user";
|
||||
|
||||
final Ref ref;
|
||||
|
||||
AccountNotifier(this.ref) : super(const AccountState());
|
||||
|
||||
Future<void> fetchAccount() async {
|
||||
var box = await Hive.openBox<TMAccount>(_boxName);
|
||||
final user = box.get(_key, defaultValue: null);
|
||||
|
||||
if (user != null) {
|
||||
ref.read(emailProvider.notifier).state = user.email;
|
||||
ref.read(passwordProvider.notifier).state = user.password;
|
||||
state = AccountState(isLoading: false, user: user);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createAccount() async {
|
||||
state = const AccountState(isLoading: true);
|
||||
|
||||
try {
|
||||
TMAccount account = await ref.read(clientProvider).createAccount();
|
||||
var box = Hive.box<TMAccount>(_boxName);
|
||||
await box.put(_key, account);
|
||||
|
||||
ref.read(emailProvider.notifier).state = account.email;
|
||||
ref.read(passwordProvider.notifier).state = account.password;
|
||||
state = AccountState(isLoading: false, user: account);
|
||||
} catch (err) {
|
||||
state = const AccountState(isLoading: false);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> login(String? address, String? password) async {
|
||||
state = const AccountState(isLoading: true);
|
||||
|
||||
try {
|
||||
if (address != null && password != null) {
|
||||
TMAccount account =
|
||||
await ref.read(clientProvider).login(address, password);
|
||||
var box = Hive.box<TMAccount>(_boxName);
|
||||
await box.put(_key, account);
|
||||
|
||||
state = AccountState(isLoading: false, user: account);
|
||||
}
|
||||
} catch (err) {
|
||||
state = const AccountState(isLoading: false);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
AccountState previous = state;
|
||||
try {
|
||||
var box = Hive.box<TMAccount>(_boxName);
|
||||
await box.delete(_key);
|
||||
state = const AccountState();
|
||||
} catch (err) {
|
||||
state = previous;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +1,57 @@
|
|||
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/client.dart";
|
||||
import "package:smol_mail/smol/proto.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();
|
||||
});
|
||||
|
||||
class Folder extends Notifier<String> {
|
||||
@override
|
||||
String build() => "inbox";
|
||||
|
||||
void select(String folder) => state = folder;
|
||||
}
|
||||
|
||||
final folderProvider = NotifierProvider<Folder, String>(Folder.new);
|
||||
|
||||
final messagesProvider = Provider<List<MailRecord>>((ref) {
|
||||
ref.watch(revisionProvider);
|
||||
return ref.watch(storeProvider).listMessages(ref.watch(folderProvider));
|
||||
});
|
||||
|
||||
final unreadProvider = Provider<int>((ref) {
|
||||
ref.watch(revisionProvider);
|
||||
return ref.watch(storeProvider).unreadCount();
|
||||
});
|
||||
|
||||
final contactsProvider = Provider<List<(String, StoredContact)>>((ref) {
|
||||
ref.watch(revisionProvider);
|
||||
return ref.watch(storeProvider).allContacts();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,23 +1,21 @@
|
|||
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: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();
|
||||
final store = await SmolStore.open();
|
||||
applyPresetServer(store);
|
||||
|
||||
runApp(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
ProviderScope(
|
||||
overrides: [storeProvider.overrideWithValue(store)],
|
||||
child: const AppWidget(),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,53 @@
|
|||
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/theme/theme.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.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> {
|
||||
@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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp.router(
|
||||
title: "FlashMail",
|
||||
theme: appTheme,
|
||||
title: "kirakira",
|
||||
theme: appThemeLight,
|
||||
darkTheme: appThemeDark,
|
||||
themeMode: ThemeMode.system,
|
||||
debugShowCheckedModeBanner: false,
|
||||
scaffoldMessengerKey: rootScaffoldMessengerKey,
|
||||
routerConfig: AppRouter(ref).config(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
class AccountGuard extends AutoRouteGuard {
|
||||
final WidgetRef ref;
|
||||
|
||||
AccountGuard(this.ref);
|
||||
|
||||
@override
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) {
|
||||
if (!ref.read(accountProvider).isAuthenticated()) {
|
||||
router.replaceNamed("/");
|
||||
} else {
|
||||
resolver.next(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flash_mail/presentation/routes/account_guard.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";
|
||||
|
||||
@AutoRouterConfig(replaceInRouteName: "Screen,Route")
|
||||
class AppRouter extends $AppRouter {
|
||||
class AppRouter extends RootStackRouter {
|
||||
final WidgetRef ref;
|
||||
|
||||
AppRouter(this.ref);
|
||||
|
|
@ -15,23 +14,40 @@ class AppRouter extends $AppRouter {
|
|||
@override
|
||||
List<AutoRoute> get routes => [
|
||||
AutoRoute(
|
||||
page: AccountRoute.page,
|
||||
page: OnboardingRoute.page,
|
||||
path: "/",
|
||||
initial: true,
|
||||
guards: [AuthGuard(ref)],
|
||||
),
|
||||
AutoRoute(
|
||||
page: LoginRoute.page,
|
||||
path: "/login",
|
||||
guards: [HomeGuard(ref)],
|
||||
),
|
||||
AutoRoute(
|
||||
page: InboxRoute.page,
|
||||
path: "/inbox",
|
||||
guards: [AccountGuard(ref)],
|
||||
guards: [IdentityGuard(ref)],
|
||||
),
|
||||
AutoRoute(
|
||||
page: MessageDetailRoute.page,
|
||||
path: "/message",
|
||||
)
|
||||
guards: [IdentityGuard(ref)],
|
||||
),
|
||||
AutoRoute(
|
||||
page: ComposeRoute.page,
|
||||
path: "/compose",
|
||||
guards: [IdentityGuard(ref)],
|
||||
),
|
||||
AutoRoute(
|
||||
page: ContactsRoute.page,
|
||||
path: "/contacts",
|
||||
guards: [IdentityGuard(ref)],
|
||||
),
|
||||
AutoRoute(
|
||||
page: ContactDetailRoute.page,
|
||||
path: "/contact",
|
||||
guards: [IdentityGuard(ref)],
|
||||
),
|
||||
AutoRoute(
|
||||
page: SettingsRoute.page,
|
||||
path: "/settings",
|
||||
guards: [IdentityGuard(ref)],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// dart format width=80
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
// **************************************************************************
|
||||
|
|
@ -8,132 +9,240 @@
|
|||
// 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 _i8;
|
||||
import 'package:flutter/material.dart' as _i9;
|
||||
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/inbox_screen.dart' as _i4;
|
||||
import 'package:smol_mail/presentation/screens/message_detail_screen.dart'
|
||||
as _i5;
|
||||
import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i6;
|
||||
import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i7;
|
||||
|
||||
/// generated route for
|
||||
/// [_i1.ComposeScreen]
|
||||
class ComposeRoute extends _i8.PageRouteInfo<ComposeRouteArgs> {
|
||||
ComposeRoute({
|
||||
_i9.Key? key,
|
||||
String? to,
|
||||
String? subject,
|
||||
List<_i8.PageRouteInfo>? children,
|
||||
}) : super(
|
||||
ComposeRoute.name,
|
||||
args: ComposeRouteArgs(key: key, to: to, subject: subject),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'ComposeRoute';
|
||||
|
||||
static _i8.PageInfo page = _i8.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 _i9.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 _i8.PageRouteInfo<ContactDetailRouteArgs> {
|
||||
ContactDetailRoute({
|
||||
_i9.Key? key,
|
||||
required String address,
|
||||
List<_i8.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 _i8.PageInfo page = _i8.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 _i9.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 _i8.PageRouteInfo<void> {
|
||||
const ContactsRoute({List<_i8.PageRouteInfo>? children})
|
||||
: super(ContactsRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'ContactsRoute';
|
||||
|
||||
static _i8.PageInfo page = _i8.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i3.ContactsScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i4.InboxScreen]
|
||||
class InboxRoute extends _i8.PageRouteInfo<void> {
|
||||
const InboxRoute({List<_i8.PageRouteInfo>? children})
|
||||
: super(InboxRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'InboxRoute';
|
||||
|
||||
static const _i5.PageInfo<void> page = _i5.PageInfo<void>(name);
|
||||
static _i8.PageInfo page = _i8.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i4.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> {
|
||||
/// [_i5.MessageDetailScreen]
|
||||
class MessageDetailRoute extends _i8.PageRouteInfo<MessageDetailRouteArgs> {
|
||||
MessageDetailRoute({
|
||||
_i6.Key? key,
|
||||
_i9.Key? key,
|
||||
required String folder,
|
||||
required String id,
|
||||
required String subject,
|
||||
List<_i5.PageRouteInfo>? children,
|
||||
List<_i8.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 _i8.PageInfo page = _i8.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<MessageDetailRouteArgs>();
|
||||
return _i5.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 _i9.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
|
||||
/// [_i6.OnboardingScreen]
|
||||
class OnboardingRoute extends _i8.PageRouteInfo<void> {
|
||||
const OnboardingRoute({List<_i8.PageRouteInfo>? children})
|
||||
: super(OnboardingRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'OnboardingRoute';
|
||||
|
||||
static _i8.PageInfo page = _i8.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i6.OnboardingScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i7.SettingsScreen]
|
||||
class SettingsRoute extends _i8.PageRouteInfo<void> {
|
||||
const SettingsRoute({List<_i8.PageRouteInfo>? children})
|
||||
: super(SettingsRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'SettingsRoute';
|
||||
|
||||
static _i8.PageInfo page = _i8.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i7.SettingsScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
class AuthGuard extends AutoRouteGuard {
|
||||
final WidgetRef ref;
|
||||
|
||||
AuthGuard(this.ref);
|
||||
|
||||
@override
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) {
|
||||
if (ref.read(accountProvider).isAuthenticated()) {
|
||||
router.replaceNamed("/inbox");
|
||||
} else {
|
||||
resolver.next(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
lib/presentation/routes/home_guard.dart
Normal file
23
lib/presentation/routes/home_guard.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
||||
|
||||
/// The landing screen is only for users who cannot use the app yet: no
|
||||
/// identity, or no registered account.
|
||||
class HomeGuard extends AutoRouteGuard {
|
||||
final WidgetRef ref;
|
||||
|
||||
HomeGuard(this.ref);
|
||||
|
||||
@override
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) {
|
||||
if (ref.read(identityProvider) != null &&
|
||||
ref.read(accountProvider) != null) {
|
||||
router.replace(InboxRoute());
|
||||
} else {
|
||||
resolver.next(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
lib/presentation/routes/identity_guard.dart
Normal file
22
lib/presentation/routes/identity_guard.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
||||
|
||||
/// Everything past onboarding needs an identity; the account can still be
|
||||
/// registered from settings.
|
||||
class IdentityGuard extends AutoRouteGuard {
|
||||
final WidgetRef ref;
|
||||
|
||||
IdentityGuard(this.ref);
|
||||
|
||||
@override
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) {
|
||||
if (ref.read(identityProvider) == null) {
|
||||
router.replace(OnboardingRoute());
|
||||
} else {
|
||||
resolver.next(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:auto_route/auto_route.dart";
|
||||
|
||||
import "package:flash_mail/presentation/widgets/auth/account_buttons.dart";
|
||||
|
||||
@RoutePage()
|
||||
class AccountScreen extends StatelessWidget {
|
||||
const AccountScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: <Widget>[
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Image(
|
||||
image: AssetImage("assets/images/logo-small.png"),
|
||||
),
|
||||
Text(
|
||||
"FlashMail",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
)
|
||||
],
|
||||
),
|
||||
const AccountButtons()
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
127
lib/presentation/screens/compose_screen.dart
Normal file
127
lib/presentation/screens/compose_screen.dart
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/widgets/primary_button.dart";
|
||||
import "package:smol_mail/presentation/widgets/small_loading_spinner.dart";
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
|
||||
/// One tap seals one envelope; the button disables while the send is in
|
||||
/// flight so a second tap cannot seal a second message.
|
||||
@RoutePage()
|
||||
class ComposeScreen extends ConsumerStatefulWidget {
|
||||
final String? to;
|
||||
final String? subject;
|
||||
|
||||
const ComposeScreen({
|
||||
super.key,
|
||||
this.to,
|
||||
this.subject,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ComposeScreen> createState() => _ComposeScreenState();
|
||||
}
|
||||
|
||||
class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
late final toController = TextEditingController(text: widget.to ?? "");
|
||||
late final subjectController =
|
||||
TextEditingController(text: widget.subject ?? "");
|
||||
final bodyController = TextEditingController();
|
||||
bool anonymous = false;
|
||||
bool busy = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
toController.dispose();
|
||||
subjectController.dispose();
|
||||
bodyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final client = ref.read(clientProvider);
|
||||
setState(() => busy = true);
|
||||
try {
|
||||
final to = await client.send(
|
||||
toController.text,
|
||||
subjectController.text,
|
||||
bodyController.text,
|
||||
anonymous: anonymous,
|
||||
);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text("sent to $to")));
|
||||
AutoRouter.of(context).pop();
|
||||
}
|
||||
} on SmolError catch (err) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, err.message);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Write")),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: toController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "To",
|
||||
hintText: "user@host[:1961] or smol://user@host/key",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
TextField(
|
||||
controller: subjectController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Subject",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
TextField(
|
||||
controller: bodyController,
|
||||
maxLines: 12,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Body",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: anonymous,
|
||||
onChanged: (value) => setState(() => anonymous = value),
|
||||
title: Text(
|
||||
"Anonymous (omit the signed Reply-To)",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Center(
|
||||
child: PrimaryButton(
|
||||
onPressed: busy ? () {} : _send,
|
||||
child: busy ? const SmallLoadingSpinner() : const Text("Send"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
158
lib/presentation/screens/contact_detail_screen.dart
Normal file
158
lib/presentation/screens/contact_detail_screen.dart
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
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/errors.dart";
|
||||
import "package:smol_mail/smol/proto.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;
|
||||
|
||||
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) {
|
||||
final contact = ref.watch(storeProvider).contact(widget.address);
|
||||
|
||||
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),
|
||||
_field(context, "fingerprint", fingerprint(contact.key)),
|
||||
_field(context, "public key", b32encode(contact.key)),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _field(
|
||||
context,
|
||||
"address",
|
||||
parseAddress(widget.address)
|
||||
.uri(contact.key)),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
onPressed: () => copyText(
|
||||
context,
|
||||
parseAddress(widget.address).uri(contact.key)),
|
||||
),
|
||||
],
|
||||
),
|
||||
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(b32encode(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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
130
lib/presentation/screens/contacts_screen.dart
Normal file
130
lib/presentation/screens/contacts_screen.dart
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
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/shared/utils/snackbar.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/proto.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});
|
||||
|
||||
Future<void> _import(BuildContext context, WidgetRef ref) async {
|
||||
final client = ref.read(clientProvider);
|
||||
final controller = TextEditingController();
|
||||
final text = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
title: const Text("Import contact"),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: "smol://user@host/key",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(controller.text),
|
||||
child: const Text("Import"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (text == null || text.trim().isEmpty) return;
|
||||
try {
|
||||
client.importContact(text);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("contact imported (verified key)")),
|
||||
);
|
||||
}
|
||||
} on SmolError catch (err) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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, ref),
|
||||
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];
|
||||
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,
|
||||
children: [
|
||||
_chip(context, contact.verified ? "verified" : "tofu",
|
||||
ok: contact.verified),
|
||||
if (contact.history.isNotEmpty)
|
||||
_chip(context, "${contact.history.length} previous",
|
||||
ok: false),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,62 +1,82 @@
|
|||
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/drawer/app_drawer.dart";
|
||||
import "package:smol_mail/presentation/widgets/image_banner.dart";
|
||||
import "package:smol_mail/presentation/widgets/message/message_list.dart";
|
||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
|
||||
/// No server push in v1 (SPEC.md §13): new mail arrives when the user taps
|
||||
/// fetch, which pulls everything and acknowledges what verifies.
|
||||
@RoutePage()
|
||||
class InboxScreen extends ConsumerStatefulWidget {
|
||||
class InboxScreen extends ConsumerWidget {
|
||||
const InboxScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<InboxScreen> createState() => _InboxScreenState();
|
||||
}
|
||||
|
||||
class _InboxScreenState extends ConsumerState<InboxScreen> {
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(timer) => ref.refresh(messagesProvider),
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final messages = ref.watch(messagesProvider);
|
||||
final folder = ref.watch(folderProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Inbox"),
|
||||
title: Text(folder == "inbox" ? "Inbox" : "Sent"),
|
||||
actions: [
|
||||
if (folder == "inbox")
|
||||
IconButton(
|
||||
tooltip: "Fetch",
|
||||
onPressed: () => _fetch(context, ref),
|
||||
icon: const Icon(Icons.cloud_download),
|
||||
),
|
||||
],
|
||||
),
|
||||
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: messages.isEmpty
|
||||
? RefreshIndicator(
|
||||
onRefresh: () => folder == "inbox"
|
||||
? _fetch(context, ref)
|
||||
: Future.value(),
|
||||
child: ListView(
|
||||
children: const [
|
||||
ImageBanner(
|
||||
imgSrc: "assets/images/empty.png",
|
||||
text: "Nothing here yet!",
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: MessageList(
|
||||
onRefresh: () => folder == "inbox"
|
||||
? _fetch(context, ref)
|
||||
: Future.value(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:auto_route/auto_route.dart";
|
||||
|
||||
import "package:flash_mail/presentation/widgets/auth/login_form.dart";
|
||||
|
||||
@RoutePage()
|
||||
class LoginScreen extends StatelessWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(25),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
"Login",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
LoginForm(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +1,128 @@
|
|||
import "dart:typed_data";
|
||||
|
||||
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";
|
||||
|
||||
/// Fetched mail is acknowledged off the server during fetch, so deleting here
|
||||
/// removes only the local sealed copy.
|
||||
@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) async {
|
||||
await ref.read(storeProvider).deleteMessage(folder, id);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (context.mounted) {
|
||||
AutoRouter.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _nameSender(BuildContext context, WidgetRef ref,
|
||||
Uint8List 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 local copy",
|
||||
onPressed: () => _delete(context, ref),
|
||||
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(
|
||||
record: record,
|
||||
opened: opened,
|
||||
onNameSender: opened.sender == null
|
||||
? null
|
||||
: (key) => _nameSender(context, ref, key),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
324
lib/presentation/screens/onboarding_screen.dart
Normal file
324
lib/presentation/screens/onboarding_screen.dart
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
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/crypto.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/proto.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? createdSeed;
|
||||
bool busy = false;
|
||||
|
||||
final seedController = TextEditingController();
|
||||
final restoreAddressController = TextEditingController();
|
||||
final addressController = TextEditingController();
|
||||
final serverKeyController = TextEditingController();
|
||||
final tokenController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
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());
|
||||
}
|
||||
|
||||
void _createIdentity() {
|
||||
final client = ref.read(clientProvider);
|
||||
final fresh = client.createIdentity();
|
||||
setState(() {
|
||||
createdSeed = fresh.seed;
|
||||
step = _Step.backup;
|
||||
});
|
||||
}
|
||||
|
||||
void _restoreIdentity() {
|
||||
setState(() => step = _Step.restore);
|
||||
}
|
||||
|
||||
// Restoring must succeed on its own even if recall fails (server not
|
||||
// pinned yet, offline, typo) — recall can always be retried from the
|
||||
// register step or settings afterward.
|
||||
void _submitRestore() {
|
||||
final client = ref.read(clientProvider);
|
||||
try {
|
||||
client.restoreIdentity(seedController.text);
|
||||
} on Exception catch (err) {
|
||||
_showError(err);
|
||||
return;
|
||||
}
|
||||
final addressText = restoreAddressController.text.trim();
|
||||
if (addressText.isEmpty) {
|
||||
setState(() => step = _Step.register);
|
||||
return;
|
||||
}
|
||||
() async {
|
||||
try {
|
||||
await client.recallAccount(addressText);
|
||||
if (!mounted) return;
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
AutoRouter.of(context).replace(InboxRoute());
|
||||
} on Exception catch (err) {
|
||||
if (!mounted) return;
|
||||
_showError(err);
|
||||
setState(() => step = _Step.register);
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
Future<void> _submitRegistration() async {
|
||||
await _submit(recall: false);
|
||||
}
|
||||
|
||||
// Restoring a seed on a new device knows the identity but not the address it
|
||||
// was registered under; recall binds it 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);
|
||||
}
|
||||
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(InboxRoute());
|
||||
}
|
||||
} catch (err) {
|
||||
_showError(err);
|
||||
} finally {
|
||||
if (mounted) setState(() => busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Padding(
|
||||
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) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 60),
|
||||
Row(
|
||||
children: [
|
||||
const Image(image: AssetImage("assets/images/logo-small.png")),
|
||||
const SizedBox(width: 10),
|
||||
Text("kirakira", 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 Spacer(),
|
||||
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 seed = createdSeed!;
|
||||
final seedHex = hex(seed);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_header(context, "Back up this seed; it is the only secret."),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
"fingerprint:\n${fingerprint(ed25519PublicKey(seed))}",
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SelectableText(
|
||||
seedHex,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall!
|
||||
.copyWith(color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: seedHex));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
const SnackBar(content: Text("Seed copied to clipboard")),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text("Copy seed"),
|
||||
),
|
||||
const Spacer(),
|
||||
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 seed 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 (if already registered)",
|
||||
hintText: "alice@example.org",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
PrimaryButton(
|
||||
onPressed: _submitRestore,
|
||||
child: const Text("Restore"),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
SecondaryButton(
|
||||
text: "Back",
|
||||
onPressed: () => setState(() => step = _Step.welcome),
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _register(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_header(context,
|
||||
"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,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
TextField(
|
||||
controller: tokenController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Invite token (optional)",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
PrimaryButton(
|
||||
onPressed: busy ? () {} : _submitRegistration,
|
||||
child: busy
|
||||
? const SmallLoadingSpinner()
|
||||
: const Text("Pin and Register"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: busy ? () {} : _submitRecall,
|
||||
child: const Text("Already registered? Recall"),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
425
lib/presentation/screens/settings_screen.dart
Normal file
425
lib/presentation/screens/settings_screen.dart
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
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/shared/utils/snackbar.dart";
|
||||
import "package:smol_mail/smol/crypto.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/proto.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 data = ref.read(storeProvider).exportData();
|
||||
final stamp = DateTime.now().toIso8601String().substring(0, 10);
|
||||
final file = File("${Directory.systemTemp.path}/smolmail-export-$stamp.json");
|
||||
await file.writeAsString(jsonEncode(data));
|
||||
await SharePlus.instance.share(ShareParams(
|
||||
files: [XFile(file.path)], text: "kirakira backup $stamp"));
|
||||
if (mounted) {
|
||||
final messages = (data["inbox"] as List).length + (data["sent"] as List).length;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("exported $messages messages, "
|
||||
"${(data["contacts"] as Map).length} contacts, "
|
||||
"${(data["servers"] as Map).length} server keys")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 data = jsonDecode(File(path).readAsStringSync()) as Map;
|
||||
final summary = await ref.read(storeProvider).importData(data);
|
||||
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 ${b32encode(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 seedHex = hex(me.seed);
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_card(
|
||||
context,
|
||||
borderColor: Theme.of(context).colorScheme.error,
|
||||
children: [
|
||||
Text("seed", style: Theme.of(context).textTheme.labelSmall),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
seedShown ? seedHex : "•" * 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: seedHex)),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
"back this seed up; it is the only secret",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall!
|
||||
.copyWith(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
"retired keys: ${store.identities().length - 1} (kept to read old mail)",
|
||||
style: Theme.of(context).textTheme.labelSmall),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
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(
|
||||
b32encode(key),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
onPressed: () {
|
||||
store.unpinServer(host);
|
||||
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);
|
||||
keyController.clear();
|
||||
setState(() {});
|
||||
} on SmolError catch (err) {
|
||||
_showError(err);
|
||||
} catch (err) {
|
||||
_showError(err);
|
||||
}
|
||||
},
|
||||
child: const Text("Pin server"),
|
||||
),
|
||||
if (account == null) ...[
|
||||
const SizedBox(height: 30),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 20),
|
||||
Text("register an address",
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: addressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Address",
|
||||
hintText: "you@example.org[:1961]",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: tokenController,
|
||||
decoration: InputDecoration(
|
||||
labelText: "Invite token (optional)",
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: _register,
|
||||
child: const Text("Register"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _recall,
|
||||
child: const Text("Already registered? Recall"),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 30),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 20),
|
||||
Text("backup", style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
"Mail, once fetched, exists only on this device. Export backs up the "
|
||||
"inbox, sent mail, contacts and server pins — never the seed.",
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _export,
|
||||
icon: const Icon(Icons.share, size: 18),
|
||||
label: const Text("Export"),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _import,
|
||||
icon: const Icon(Icons.file_upload, size: 18),
|
||||
label: const Text("Import"),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 10),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text("Rotate identity key"),
|
||||
trailing: const Icon(Icons.rotate_right),
|
||||
onTap: _rotate,
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text("Wipe identity and messages",
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error)),
|
||||
trailing: Icon(Icons.delete_forever,
|
||||
color: Theme.of(context).colorScheme.error),
|
||||
onTap: _wipe,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card(BuildContext context,
|
||||
{required List<Widget> children, Color? borderColor}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: borderColor == null ? null : Border.all(color: borderColor),
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
78
lib/presentation/theme/app_colors.dart
Normal file
78
lib/presentation/theme/app_colors.dart
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
/// Theme-aware colors that don't map onto a standard [ColorScheme] role.
|
||||
/// Access via `Theme.of(context).extension<AppColors>()!`.
|
||||
class AppColors extends ThemeExtension<AppColors> {
|
||||
const AppColors({
|
||||
required this.cardFill,
|
||||
required this.highlight,
|
||||
required this.snippetText,
|
||||
required this.avatarPalette,
|
||||
});
|
||||
|
||||
/// Elevated surface used for input fields, list tiles, and cards.
|
||||
final Color cardFill;
|
||||
|
||||
/// Pressed/selected state highlight (e.g. active drawer item).
|
||||
final Color highlight;
|
||||
|
||||
/// Dimmed body text, e.g. message preview snippets.
|
||||
final Color snippetText;
|
||||
|
||||
/// Deterministic background colors for contact avatars.
|
||||
final List<Color> avatarPalette;
|
||||
|
||||
static const dark = AppColors(
|
||||
cardFill: Color.fromRGBO(22, 39, 56, 1),
|
||||
highlight: Color.fromRGBO(40, 70, 100, 1),
|
||||
snippetText: Color.fromRGBO(193, 203, 213, 1),
|
||||
avatarPalette: [
|
||||
Color.fromRGBO(63, 207, 142, 1),
|
||||
Color.fromRGBO(90, 156, 240, 1),
|
||||
Color.fromRGBO(240, 176, 90, 1),
|
||||
Color.fromRGBO(214, 110, 140, 1),
|
||||
Color.fromRGBO(154, 133, 230, 1),
|
||||
Color.fromRGBO(90, 200, 210, 1),
|
||||
],
|
||||
);
|
||||
|
||||
static const light = AppColors(
|
||||
cardFill: Color.fromRGBO(236, 240, 243, 1),
|
||||
highlight: Color.fromRGBO(210, 231, 248, 1),
|
||||
snippetText: Color.fromRGBO(91, 100, 112, 1),
|
||||
avatarPalette: [
|
||||
Color.fromRGBO(43, 175, 116, 1),
|
||||
Color.fromRGBO(56, 116, 203, 1),
|
||||
Color.fromRGBO(196, 132, 41, 1),
|
||||
Color.fromRGBO(180, 76, 108, 1),
|
||||
Color.fromRGBO(115, 92, 199, 1),
|
||||
Color.fromRGBO(46, 152, 163, 1),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
AppColors copyWith({
|
||||
Color? cardFill,
|
||||
Color? highlight,
|
||||
Color? snippetText,
|
||||
List<Color>? avatarPalette,
|
||||
}) {
|
||||
return AppColors(
|
||||
cardFill: cardFill ?? this.cardFill,
|
||||
highlight: highlight ?? this.highlight,
|
||||
snippetText: snippetText ?? this.snippetText,
|
||||
avatarPalette: avatarPalette ?? this.avatarPalette,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AppColors lerp(ThemeExtension<AppColors>? other, double t) {
|
||||
if (other is! AppColors) return this;
|
||||
return AppColors(
|
||||
cardFill: Color.lerp(cardFill, other.cardFill, t)!,
|
||||
highlight: Color.lerp(highlight, other.highlight, t)!,
|
||||
snippetText: Color.lerp(snippetText, other.snippetText, t)!,
|
||||
avatarPalette: t < 0.5 ? avatarPalette : other.avatarPalette,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +1,143 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
final ThemeData appTheme = ThemeData(
|
||||
colorScheme: ColorScheme.fromSwatch().copyWith(
|
||||
primary: const Color.fromRGBO(63, 207, 142, 1),
|
||||
background: const Color.fromRGBO(1, 17, 29, 1),
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Color.fromRGBO(1, 17, 29, 1),
|
||||
iconTheme: IconThemeData(
|
||||
color: Colors.white,
|
||||
size: 30,
|
||||
),
|
||||
titleTextStyle: TextStyle(
|
||||
color: Colors.white,
|
||||
fontFamily: "Inter",
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 30,
|
||||
),
|
||||
elevation: 0.0,
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
bodySmall: TextStyle(
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
|
||||
const _seedColor = Color.fromRGBO(63, 207, 142, 1);
|
||||
const _darkSurface = Color.fromRGBO(1, 17, 29, 1);
|
||||
|
||||
TextTheme _textTheme(TextTheme base, Color mutedColor) {
|
||||
return base.copyWith(
|
||||
bodySmall: base.bodySmall?.copyWith(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
bodyMedium: base.bodyMedium?.copyWith(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 20,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
bodyLarge: TextStyle(
|
||||
bodyLarge: base.bodyLarge?.copyWith(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 24,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
color: Colors.white,
|
||||
titleMedium: base.titleMedium?.copyWith(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w300,
|
||||
),
|
||||
titleLarge: TextStyle(
|
||||
titleLarge: base.titleLarge?.copyWith(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 35,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
labelSmall: TextStyle(
|
||||
labelSmall: base.labelSmall?.copyWith(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 16,
|
||||
color: Color.fromRGBO(127, 133, 140, 1),
|
||||
color: mutedColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
backgroundColor: Colors.red,
|
||||
contentTextStyle: TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color.fromRGBO(63, 207, 142, 1),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
);
|
||||
}
|
||||
|
||||
ThemeData _buildTheme(ColorScheme scheme, AppColors appColors) {
|
||||
return ThemeData(
|
||||
colorScheme: scheme,
|
||||
// ThemeData defaults this legacy field to Colors.grey[900] on a dark
|
||||
// theme when unset, which is nearly invisible against our dark surfaces —
|
||||
// several screens still read it via Theme.of(context).primaryColor.
|
||||
primaryColor: scheme.primary,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
extensions: [appColors],
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: scheme.surface,
|
||||
iconTheme: IconThemeData(color: scheme.onSurface, size: 30),
|
||||
titleTextStyle: TextStyle(
|
||||
color: scheme.onSurface,
|
||||
fontFamily: "Inter",
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 30,
|
||||
),
|
||||
elevation: 0.0,
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: const Color.fromRGBO(63, 207, 412, 1),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
textTheme: _textTheme(
|
||||
ThemeData(brightness: scheme.brightness).textTheme,
|
||||
scheme.onSurfaceVariant,
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: scheme.inverseSurface,
|
||||
contentTextStyle: TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 17,
|
||||
fontSize: 16,
|
||||
color: scheme.onInverseSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
useMaterial3: true,
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: scheme.primary,
|
||||
foregroundColor: scheme.onPrimary,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: scheme.primary,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 17,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Without this, an empty/unfocused field's resting label inherits
|
||||
// bodyLarge (24px bold) — that's what made "Host" etc. look huge.
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
labelStyle: TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
floatingLabelStyle: TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: scheme.primary,
|
||||
),
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
useMaterial3: true,
|
||||
);
|
||||
}
|
||||
|
||||
final ThemeData appThemeDark = _buildTheme(
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: _seedColor,
|
||||
brightness: Brightness.dark,
|
||||
).copyWith(surface: _darkSurface),
|
||||
AppColors.dark,
|
||||
);
|
||||
|
||||
final ThemeData appThemeLight = _buildTheme(
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: _seedColor,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
AppColors.light,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
import "package:external_path/external_path.dart";
|
||||
import "package:flash/flash.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_downloader/flutter_downloader.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flash_mail/presentation/widgets/drawer/toast_message.dart";
|
||||
|
||||
class AttachmentButton extends ConsumerWidget {
|
||||
final Map<String, dynamic> file;
|
||||
|
||||
const AttachmentButton({super.key, required this.file});
|
||||
|
||||
Future<void> downloadFile(BuildContext ctx, WidgetRef ref, String url) async {
|
||||
final token = ref.read(tokenProvider);
|
||||
showFlash(
|
||||
context: ctx,
|
||||
duration: const Duration(seconds: 2),
|
||||
builder: (context, controller) => ToastMessage(
|
||||
controller: controller,
|
||||
icon: Icons.arrow_downward,
|
||||
text: "Starting download!",
|
||||
),
|
||||
);
|
||||
|
||||
await FlutterDownloader.enqueue(
|
||||
headers: {"Authorization": "Bearer $token"},
|
||||
url: "https://api.mail.tm$url",
|
||||
savedDir: await ExternalPath.getExternalStoragePublicDirectory(
|
||||
ExternalPath.DIRECTORY_DOWNLOADS,
|
||||
),
|
||||
saveInPublicStorage: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: const Color.fromRGBO(137, 147, 164, 1),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.file_open_rounded,
|
||||
color: Colors.red,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
file["filename"],
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
splashColor: const Color.fromRGBO(40, 70, 100, 1),
|
||||
onPressed: () =>
|
||||
downloadFile(context, ref, file["downloadUrl"]),
|
||||
icon: const Icon(
|
||||
Icons.arrow_downward,
|
||||
size: 24,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
import "package:flash_mail/presentation/widgets/attachment/attachment_button.dart";
|
||||
|
||||
class AttachmentList extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> attachments;
|
||||
|
||||
const AttachmentList({super.key, required this.attachments});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: attachments.length,
|
||||
itemBuilder: (ctx, i) => AttachmentButton(file: attachments[i]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:dio/dio.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flash_mail/presentation/widgets/primary_button.dart";
|
||||
import "package:flash_mail/presentation/widgets/secondary_button.dart";
|
||||
import "package:flash_mail/presentation/widgets/small_loading_spinner.dart";
|
||||
|
||||
class AccountButtons extends StatelessWidget {
|
||||
const AccountButtons({super.key});
|
||||
|
||||
void showErrorSnackBar(BuildContext ctx, String message) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSubmit(BuildContext context, WidgetRef ref) async {
|
||||
try {
|
||||
await ref.read(accountProvider.notifier).createAccount();
|
||||
if (context.mounted) {
|
||||
AutoRouter.of(context).replaceNamed("/inbox");
|
||||
}
|
||||
} on DioError catch (err) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, err.message);
|
||||
}
|
||||
} catch (err) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, "Something went wrong!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
"Your Inbox, Your Privacy, Zero Ads.",
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
return PrimaryButton(
|
||||
child: ref.watch(accountProvider).isLoading
|
||||
? const SmallLoadingSpinner()
|
||||
: const Text("Create New Account"),
|
||||
onPressed: () => _handleSubmit(context, ref),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
SecondaryButton(
|
||||
text: "Use Existing",
|
||||
onPressed: () => AutoRouter.of(context).pushNamed("/login"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
class InputField extends StatelessWidget {
|
||||
final String label;
|
||||
final String placeholder;
|
||||
final TextInputAction inputAction;
|
||||
final String? Function(String?) validator;
|
||||
final void Function(String?) onSaved;
|
||||
final TextInputType? inputType;
|
||||
final bool? obscureText;
|
||||
|
||||
const InputField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.placeholder,
|
||||
required this.inputAction,
|
||||
required this.validator,
|
||||
required this.onSaved,
|
||||
this.inputType,
|
||||
this.obscureText,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextFormField(
|
||||
keyboardType: inputType,
|
||||
textInputAction: inputAction,
|
||||
obscureText: obscureText ?? false,
|
||||
validator: validator,
|
||||
onSaved: onSaved,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(fontSize: 18),
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: const Color.fromRGBO(22, 39, 56, 1),
|
||||
hintText: placeholder,
|
||||
hintStyle: const TextStyle(
|
||||
color: Color.fromRGBO(127, 133, 141, 1),
|
||||
),
|
||||
errorStyle: const TextStyle(
|
||||
fontFamily: "Inter",
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:dio/dio.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flash_mail/presentation/widgets/auth/input_field.dart";
|
||||
import "package:flash_mail/presentation/widgets/primary_button.dart";
|
||||
import "package:flash_mail/presentation/widgets/small_loading_spinner.dart";
|
||||
import "package:flash_mail/shared/utils/regex_patterns.dart";
|
||||
|
||||
class LoginForm extends ConsumerWidget {
|
||||
final GlobalKey<FormState> _formKey = GlobalKey();
|
||||
|
||||
LoginForm({super.key});
|
||||
|
||||
String? _validateEmail(String? value) {
|
||||
if (value!.isEmpty || !emailRegex.hasMatch(value)) {
|
||||
return "Invalid email address";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validatePassword(String? value) {
|
||||
if (value!.isEmpty || value.length < 8) {
|
||||
return "Password too short";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void showErrorSnackBar(BuildContext ctx, String message) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSubmit(BuildContext context, WidgetRef ref) async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
FocusScope.of(context).unfocus();
|
||||
_formKey.currentState!.save();
|
||||
|
||||
try {
|
||||
String? email = ref.read(emailProvider.notifier).state;
|
||||
String? password = ref.read(passwordProvider.notifier).state;
|
||||
|
||||
await ref.read(accountProvider.notifier).login(email, password);
|
||||
|
||||
if (context.mounted) {
|
||||
AutoRouter.of(context).replaceNamed("/inbox");
|
||||
}
|
||||
} on DioError catch (err) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, err.message);
|
||||
}
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, "Something went wrong!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final account = ref.watch(accountProvider);
|
||||
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
InputField(
|
||||
label: "Email Address",
|
||||
placeholder: "Enter email",
|
||||
inputType: TextInputType.emailAddress,
|
||||
inputAction: TextInputAction.next,
|
||||
validator: _validateEmail,
|
||||
onSaved: (value) => ref.read(emailProvider.notifier).state = value,
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
InputField(
|
||||
label: "Password",
|
||||
placeholder: "Enter password",
|
||||
obscureText: true,
|
||||
inputAction: TextInputAction.done,
|
||||
validator: _validatePassword,
|
||||
onSaved: (value) =>
|
||||
ref.read(passwordProvider.notifier).state = value,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Center(
|
||||
child: PrimaryButton(
|
||||
onPressed: () => _handleSubmit(context, ref),
|
||||
child: account.isLoading
|
||||
? const SmallLoadingSpinner()
|
||||
: const Text("Login"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +1,32 @@
|
|||
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";
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/smol/proto.dart";
|
||||
import "package:smol_mail/presentation/widgets/drawer/identity_header.dart";
|
||||
import "package:smol_mail/presentation/widgets/drawer/drawer_list.dart";
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
|
||||
class AppDrawer extends ConsumerWidget {
|
||||
const AppDrawer({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final account = ref.read(accountProvider);
|
||||
final identity = ref.watch(identityProvider);
|
||||
final account = ref.watch(accountProvider);
|
||||
final address = account?.short ?? "";
|
||||
final fp = identity == null ? "" : fingerprint(identity.publicKey);
|
||||
|
||||
return Drawer(
|
||||
backgroundColor: const Color.fromRGBO(22, 39, 56, 1),
|
||||
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 90),
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
UserProfile(
|
||||
email: account.user!.email,
|
||||
password: account.user!.password,
|
||||
),
|
||||
IdentityHeader(address: address, fingerprint: fp),
|
||||
const SizedBox(height: 50),
|
||||
const DrawerList(),
|
||||
UserQuota(
|
||||
value: account.user!.used / account.user!.quota,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,24 +1,19 @@
|
|||
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";
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
||||
import "package:auto_route/auto_route.dart";
|
||||
import "package:smol_mail/shared/configs/flash_mail_icons.dart";
|
||||
import "package:smol_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) {
|
||||
final unread = ref.watch(unreadProvider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 25),
|
||||
child: Column(
|
||||
|
|
@ -26,16 +21,40 @@ class DrawerList extends ConsumerWidget {
|
|||
DrawerListTile(
|
||||
color: Theme.of(context).primaryColor,
|
||||
icon: FlashMailIcons.inbox,
|
||||
title: "Inbox",
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
title: "Inbox${unread > 0 ? " ($unread)" : ""}",
|
||||
onTap: () {
|
||||
ref.read(folderProvider.notifier).select("inbox");
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
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),
|
||||
),
|
||||
DrawerListTile(
|
||||
color: Theme.of(context).primaryColor,
|
||||
icon: Icons.outgoing_mail,
|
||||
title: "Sent",
|
||||
onTap: () {
|
||||
ref.read(folderProvider.notifier).select("sent");
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
DrawerListTile(
|
||||
color: Theme.of(context).primaryColor,
|
||||
icon: Icons.contacts,
|
||||
title: "Contacts",
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
AutoRouter.of(context).push(ContactsRoute());
|
||||
},
|
||||
),
|
||||
DrawerListTile(
|
||||
color: Theme.of(context).primaryColor,
|
||||
icon: Icons.settings,
|
||||
title: "Settings",
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
AutoRouter.of(context).push(SettingsRoute());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||
|
||||
class DrawerListTile extends StatelessWidget {
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
|
|
@ -16,10 +18,11 @@ class DrawerListTile extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
splashColor: const Color.fromRGBO(40, 70, 100, 1),
|
||||
highlightColor: const Color.fromRGBO(22, 39, 56, 1),
|
||||
splashColor: appColors.highlight,
|
||||
highlightColor: appColors.cardFill,
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: onTap,
|
||||
|
|
|
|||
64
lib/presentation/widgets/drawer/identity_header.dart
Normal file
64
lib/presentation/widgets/drawer/identity_header.dart
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
import "package:smol_mail/shared/utils/format.dart";
|
||||
|
||||
/// Identity summary in the drawer: the address is what gets shared, the
|
||||
/// fingerprint is what gets verified.
|
||||
class IdentityHeader extends StatelessWidget {
|
||||
final String address;
|
||||
final String fingerprint;
|
||||
|
||||
const IdentityHeader({
|
||||
super.key,
|
||||
required this.address,
|
||||
required this.fingerprint,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
CircleAvatar(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
radius: 35,
|
||||
child: Text(
|
||||
address.isEmpty ? "?" : address[0].toUpperCase(),
|
||||
style:
|
||||
Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 30),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
InkWell(
|
||||
onTap: () => copyText(context, address),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
address.isEmpty ? "(not registered)" : address,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
InkWell(
|
||||
onTap: () => copyText(context, fingerprint),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
"fp: $fingerprint",
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:flash/flash.dart";
|
||||
|
||||
import "package:flash_mail/presentation/widgets/drawer/toast_message.dart";
|
||||
|
||||
class UserProfile extends StatelessWidget {
|
||||
final String email;
|
||||
final String password;
|
||||
|
||||
const UserProfile({
|
||||
super.key,
|
||||
required this.email,
|
||||
required this.password,
|
||||
});
|
||||
|
||||
Future<void> copyClipboard(BuildContext context, String text) async {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (context.mounted) {
|
||||
showFlash(
|
||||
context: context,
|
||||
duration: const Duration(seconds: 2),
|
||||
builder: (context, controller) => ToastMessage(
|
||||
controller: controller,
|
||||
icon: Icons.check,
|
||||
text: "Copied to Clipboard!",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
CircleAvatar(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
radius: 35,
|
||||
child: Text(
|
||||
email[0].toUpperCase(),
|
||||
style:
|
||||
Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 30),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
InkWell(
|
||||
onTap: () => copyClipboard(context, email),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
email,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
InkWell(
|
||||
onTap: () => copyClipboard(context, password),
|
||||
child: Text(
|
||||
"password: $password",
|
||||
style: const TextStyle(
|
||||
color: Color.fromRGBO(127, 133, 141, 1),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:url_launcher/url_launcher.dart";
|
||||
|
||||
import "package:flash_mail/shared/configs/flash_mail_icons.dart";
|
||||
|
||||
class UserQuota extends StatelessWidget {
|
||||
final String _url = "https://mail.tm/";
|
||||
|
||||
final double value;
|
||||
|
||||
const UserQuota({super.key, required this.value});
|
||||
|
||||
Future<void> openUrl(Uri url) async {
|
||||
if (await canLaunchUrl(url)) {
|
||||
launchUrl(url);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 30, left: 40, right: 40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Quota",
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
LinearProgressIndicator(
|
||||
backgroundColor: const Color.fromRGBO(85, 97, 112, 1),
|
||||
color: Theme.of(context).primaryColor,
|
||||
value: value,
|
||||
minHeight: 10,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
InkWell(
|
||||
onTap: () => openUrl(Uri.parse(_url)),
|
||||
highlightColor: Colors.transparent,
|
||||
splashColor: Colors.transparent,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
"Powered By Mail.tm",
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Icon(
|
||||
FlashMailIcons.redirect,
|
||||
color: Theme.of(context).primaryColor,
|
||||
size: 16,
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,10 +5,10 @@ class ImageBanner extends StatelessWidget {
|
|||
final String text;
|
||||
|
||||
const ImageBanner({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.imgSrc,
|
||||
required this.text,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
import "package:flutter/widgets.dart";
|
||||
import "package:flutter_inappwebview/flutter_inappwebview.dart";
|
||||
import "package:url_launcher/url_launcher.dart";
|
||||
|
||||
class MessageBody extends StatefulWidget {
|
||||
final String html;
|
||||
|
||||
const MessageBody({super.key, required this.html});
|
||||
|
||||
@override
|
||||
State<MessageBody> createState() => _MessageBodyState();
|
||||
}
|
||||
|
||||
class _MessageBodyState extends State<MessageBody> {
|
||||
double webviewHeight = 1;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: webviewHeight,
|
||||
child: InAppWebView(
|
||||
initialData: InAppWebViewInitialData(data: widget.html),
|
||||
initialSettings: InAppWebViewSettings(
|
||||
transparentBackground: true,
|
||||
forceDark: ForceDark.ON,
|
||||
algorithmicDarkeningAllowed: true,
|
||||
supportZoom: false,
|
||||
javaScriptEnabled: true,
|
||||
disableHorizontalScroll: false,
|
||||
disableVerticalScroll: true,
|
||||
useWideViewPort: false,
|
||||
useHybridComposition: false,
|
||||
preferredContentMode: UserPreferredContentMode.MOBILE,
|
||||
),
|
||||
onLoadStop: (controller, url) async {
|
||||
final contentHeight = await controller.getContentHeight();
|
||||
if (contentHeight != null) {
|
||||
setState(() {
|
||||
webviewHeight = contentHeight.toDouble();
|
||||
});
|
||||
}
|
||||
},
|
||||
shouldOverrideUrlLoading: (controller, navigationAction) async {
|
||||
final url = navigationAction.request.url;
|
||||
if (url != null && await canLaunchUrl(url)) {
|
||||
await launchUrl(url);
|
||||
}
|
||||
return NavigationActionPolicy.CANCEL;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:intl/intl.dart";
|
||||
|
||||
class MessageInfo extends StatelessWidget {
|
||||
final String senderAddress;
|
||||
final String receiverAddress;
|
||||
final DateTime date;
|
||||
|
||||
const MessageInfo({
|
||||
super.key,
|
||||
required this.senderAddress,
|
||||
required this.receiverAddress,
|
||||
required this.date,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 15),
|
||||
padding: const EdgeInsets.all(15),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: const Color.fromRGBO(127, 133, 140, 1),
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"From",
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.65,
|
||||
child: Text(
|
||||
senderAddress,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"To",
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.65,
|
||||
child: Text(
|
||||
receiverAddress,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
"Date",
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
DateFormat.yMMMMd().add_jm().format(date.toLocal()),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +1,27 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/data/models/message/message.dart";
|
||||
import "package:flash_mail/presentation/widgets/image_banner.dart";
|
||||
import "package:flash_mail/presentation/widgets/message/message_tile.dart";
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/widgets/message/message_tile.dart";
|
||||
|
||||
class MessageList extends StatelessWidget {
|
||||
final List<TMMessage> messages;
|
||||
class MessageList extends ConsumerWidget {
|
||||
final Future<void> Function() onRefresh;
|
||||
|
||||
const MessageList({
|
||||
super.key,
|
||||
required this.messages,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final messages = ref.watch(messagesProvider);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: onRefresh,
|
||||
child: messages.isEmpty
|
||||
? const ImageBanner(
|
||||
imgSrc: "assets/images/empty.png",
|
||||
text: "Your inbox is empty!",
|
||||
)
|
||||
: ListView.builder(
|
||||
itemBuilder: (ctx, i) => MessageTile(message: messages[i]),
|
||||
itemCount: messages.length,
|
||||
),
|
||||
child: ListView.builder(
|
||||
itemBuilder: (ctx, i) => MessageTile(record: messages[i]),
|
||||
itemCount: messages.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
import "package:flash_mail/presentation/widgets/message/message_info.dart";
|
||||
|
||||
class MessageSender extends StatefulWidget {
|
||||
final String senderName;
|
||||
final String senderAddress;
|
||||
final String receiverAddress;
|
||||
final DateTime date;
|
||||
|
||||
const MessageSender({
|
||||
super.key,
|
||||
required this.senderName,
|
||||
required this.senderAddress,
|
||||
required this.receiverAddress,
|
||||
required this.date,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MessageSender> createState() => _MessageSenderState();
|
||||
}
|
||||
|
||||
class _MessageSenderState extends State<MessageSender> {
|
||||
bool showInfo = false;
|
||||
|
||||
void toggleInfo() {
|
||||
setState(() {
|
||||
showInfo = !showInfo;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Image.asset("assets/images/avatar.png", width: 50, height: 50),
|
||||
const SizedBox(width: 15),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.senderName,
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => toggleInfo(),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
child: Text(
|
||||
widget.senderAddress,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
showInfo
|
||||
? Icons.keyboard_arrow_up_outlined
|
||||
: Icons.keyboard_arrow_down_outlined,
|
||||
color: const Color.fromRGBO(127, 133, 140, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
showInfo
|
||||
? MessageInfo(
|
||||
senderAddress: widget.senderAddress,
|
||||
receiverAddress: widget.receiverAddress,
|
||||
date: widget.date,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,77 +1,101 @@
|
|||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/data/models/message/message.dart";
|
||||
import "package:flash_mail/presentation/routes/app_router.gr.dart";
|
||||
import "package:flash_mail/shared/utils/random_color.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/shared/utils/avatar_color.dart";
|
||||
import "package:smol_mail/shared/utils/format.dart";
|
||||
import "package:smol_mail/smol/proto.dart";
|
||||
import "package:smol_mail/smol/store.dart";
|
||||
|
||||
class MessageTile extends StatefulWidget {
|
||||
final TMMessage message;
|
||||
class MessageTile extends ConsumerWidget {
|
||||
final MailRecord record;
|
||||
|
||||
const MessageTile({super.key, required this.message});
|
||||
const MessageTile({super.key, required this.record});
|
||||
|
||||
@override
|
||||
State<MessageTile> createState() => _MessageTileState();
|
||||
}
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final folder = ref.watch(folderProvider);
|
||||
final store = ref.watch(storeProvider);
|
||||
final opened = ref.read(clientProvider).describe(record);
|
||||
final appColors = Theme.of(context).extension<AppColors>()!;
|
||||
|
||||
class _MessageTileState extends State<MessageTile> {
|
||||
late Color randomColor;
|
||||
String who;
|
||||
if (folder == "sent") {
|
||||
who = record.recipient ?? "";
|
||||
} else if (opened.sender != null) {
|
||||
final known = store.addressForKey(opened.sender!);
|
||||
who = known ?? "unknown · ${b32encode(opened.sender!).substring(0, 4)}";
|
||||
} else {
|
||||
who = "?";
|
||||
}
|
||||
final unread = folder == "inbox" && !store.isRead(record.id);
|
||||
final errorText = opened.error != null ? "<unreadable>" : null;
|
||||
final subject =
|
||||
errorText ?? (opened.subject.isEmpty ? "(no subject)" : opened.subject);
|
||||
final snippet = errorText == null
|
||||
? opened.body.replaceAll(RegExp(r"\s+"), " ").trim()
|
||||
: "";
|
||||
final avatarColor = colorForKey(who, appColors.avatarPalette);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
randomColor = getRandomColor();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
splashColor: const Color.fromRGBO(40, 70, 100, 1),
|
||||
focusColor: const Color.fromRGBO(22, 39, 56, 1),
|
||||
onTap: () => AutoRouter.of(context).push(
|
||||
MessageDetailRoute(
|
||||
id: widget.message.id,
|
||||
subject: widget.message.subject,
|
||||
),
|
||||
),
|
||||
splashColor: appColors.highlight,
|
||||
focusColor: appColors.cardFill,
|
||||
onTap: () {
|
||||
if (folder == "inbox") {
|
||||
store.markRead(record.id);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
}
|
||||
AutoRouter.of(context).push(
|
||||
MessageDetailRoute(folder: folder, id: record.id),
|
||||
);
|
||||
},
|
||||
leading: CircleAvatar(
|
||||
radius: 25,
|
||||
backgroundColor: randomColor,
|
||||
backgroundColor: avatarColor,
|
||||
child: Text(
|
||||
widget.message.from["name"]!.toUpperCase()[0],
|
||||
who.isEmpty ? "?" : who[0].toUpperCase(),
|
||||
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
|
||||
color: Colors.black,
|
||||
color: contrastingTextColor(avatarColor),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
widget.message.from["name"] as String,
|
||||
who,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
fontWeight:
|
||||
widget.message.seen ? FontWeight.w500 : FontWeight.bold,
|
||||
fontWeight: unread ? FontWeight.bold : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.message.subject,
|
||||
subject,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight:
|
||||
widget.message.seen ? FontWeight.w300 : FontWeight.w500,
|
||||
fontWeight: unread ? FontWeight.w500 : FontWeight.w300,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.message.intro ?? widget.message.subject,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: const Color.fromRGBO(193, 203, 213, 1),
|
||||
),
|
||||
)
|
||||
if (snippet.isNotEmpty)
|
||||
Text(
|
||||
snippet.length > 140 ? snippet.substring(0, 140) : snippet,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: appColors.snippetText,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
trailing: Text(
|
||||
formatShortTime(folder == "sent" ? record.sentAt : opened.time),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,190 @@
|
|||
import "package:flutter/widgets.dart";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:auto_route/auto_route.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
|
||||
import "package:flash_mail/data/models/message_detail/message_detail.dart";
|
||||
import "package:flash_mail/data/providers/providers.dart";
|
||||
import "package:flash_mail/presentation/widgets/attachment/attachment_list.dart";
|
||||
import "package:flash_mail/presentation/widgets/message/message_body.dart";
|
||||
import "package:flash_mail/presentation/widgets/message/message_sender.dart";
|
||||
import "package:smol_mail/data/providers/providers.dart";
|
||||
import "package:smol_mail/presentation/routes/app_router.gr.dart";
|
||||
import "package:smol_mail/shared/utils/format.dart";
|
||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||
import "package:smol_mail/smol/client.dart";
|
||||
import "package:smol_mail/smol/proto.dart";
|
||||
import "package:smol_mail/smol/store.dart";
|
||||
|
||||
class MessageView extends StatelessWidget {
|
||||
final TMMessageDetail message;
|
||||
/// The opened message: trust row (fingerprint and how the sender's key is
|
||||
/// known), frontmatter fields, and the plain body — smol mail has no HTML.
|
||||
class MessageView extends ConsumerWidget {
|
||||
final MailRecord record;
|
||||
final OpenedRecord opened;
|
||||
final void Function(Uint8List senderKey)? onNameSender;
|
||||
|
||||
const MessageView({super.key, required this.message});
|
||||
const MessageView({
|
||||
super.key,
|
||||
required this.record,
|
||||
required this.opened,
|
||||
this.onNameSender,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (opened.error != null) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("<unreadable: ${opened.error}>",
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final folder = ref.watch(folderProvider);
|
||||
final store = ref.watch(storeProvider);
|
||||
final client = ref.read(clientProvider);
|
||||
|
||||
// Sent copies carry no signature from a third party; the trust row then
|
||||
// just says what they are.
|
||||
final isSent = folder == "sent";
|
||||
final senderKey = opened.sender;
|
||||
final known = senderKey == null ? null : store.addressForKey(senderKey);
|
||||
final contact = known == null ? null : store.contact(known);
|
||||
final verifiedSender = contact?.verified ?? false;
|
||||
final replyTo = isSent ? null : client.replyAddress(opened);
|
||||
|
||||
final subject =
|
||||
opened.subject.isEmpty ? "(no subject)" : opened.subject;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Consumer(
|
||||
builder: (context, ref, child) => MessageSender(
|
||||
senderName: message.from["name"] as String,
|
||||
senderAddress: message.from["address"] as String,
|
||||
receiverAddress: ref.read(emailProvider) as String,
|
||||
date: message.createdAt,
|
||||
Text(subject, style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 14),
|
||||
if (isSent) _badge(context, "your own sealed copy") else
|
||||
_badge(
|
||||
context,
|
||||
verifiedSender
|
||||
? "verified sender key"
|
||||
: known != null
|
||||
? "key pinned on first use"
|
||||
: "key bound to no address",
|
||||
alert: known == null,
|
||||
),
|
||||
if (!isSent && senderKey != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
_keyBlock(context, "${fingerprint(senderKey)}\n${b32encode(senderKey)}"),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
_field(context, isSent ? "To" : "From",
|
||||
isSent ? (record.recipient ?? "") : (known ?? "unknown sender")),
|
||||
_field(context, "Date", formatTime(opened.time)),
|
||||
for (final entry in opened.fields.entries)
|
||||
if (entry.key != "Subject" && entry.key != "Reply-To")
|
||||
_field(context, entry.key, entry.value),
|
||||
if (!isSent && replyTo != null)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _field(context, "Reply-To", replyTo.short)),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
await client.saveReplyAddress(replyTo, senderKey!);
|
||||
ref.read(revisionProvider.notifier).bump();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Address saved for this sender")),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, err.toString());
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text("Save"),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!isSent && senderKey != null && onNameSender != null)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: () => onNameSender!(senderKey),
|
||||
child: const Text("Name this sender"),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 20),
|
||||
SelectableText(
|
||||
opened.body,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
MessageBody(
|
||||
html: message.html[0],
|
||||
),
|
||||
message.hasAttachments
|
||||
? AttachmentList(
|
||||
attachments: message.attachments!,
|
||||
)
|
||||
: const SizedBox.shrink()
|
||||
if (!isSent && senderKey != null)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.reply, size: 18),
|
||||
label: const Text("Reply"),
|
||||
onPressed: () => AutoRouter.of(context).push(
|
||||
ComposeRoute(
|
||||
to: known ?? replyTo?.short ?? "",
|
||||
subject: replySubject(opened.subject),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _badge(BuildContext context, String text, {bool alert = false}) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final background = alert ? scheme.errorContainer : scheme.primaryContainer;
|
||||
final foreground = alert ? scheme.onErrorContainer : scheme.onPrimaryContainer;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelSmall!
|
||||
.copyWith(color: foreground, fontWeight: FontWeight.w500),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Technical detail (fingerprint, full key) — quiet by design, so it never
|
||||
/// competes with the subject or body for attention.
|
||||
Widget _keyBlock(BuildContext context, String text) => Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
fontSize: 12,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _field(BuildContext context, String label, String value) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: Text(label, style: Theme.of(context).textTheme.labelSmall),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value, style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
class ListShimmer extends StatelessWidget {
|
||||
const ListShimmer({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
color: const Color.fromRGBO(160, 176, 186, 1),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
color: const Color.fromRGBO(160, 176, 186, 1),
|
||||
width: MediaQuery.of(context).size.width * 0.65,
|
||||
height: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
color: const Color.fromRGBO(160, 176, 186, 1),
|
||||
width: MediaQuery.of(context).size.width * 0.65,
|
||||
height: 18,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:shimmer/shimmer.dart";
|
||||
|
||||
import "package:flash_mail/presentation/widgets/shimmer/list_shimmer.dart";
|
||||
|
||||
class MessageShimmer extends StatelessWidget {
|
||||
const MessageShimmer({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(15),
|
||||
child: Shimmer.fromColors(
|
||||
baseColor: Colors.grey[500]!,
|
||||
highlightColor: Colors.grey[300]!,
|
||||
child: ListView.builder(
|
||||
itemBuilder: (ctx, i) => const Column(
|
||||
children: [
|
||||
ListShimmer(),
|
||||
SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
itemCount: 8,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
class SmallLoadingSpinner extends StatelessWidget {
|
||||
const SmallLoadingSpinner({Key? key}) : super(key: key);
|
||||
const SmallLoadingSpinner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SizedBox(
|
||||
return SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
// ignore_for_file: constant_identifier_names
|
||||
///
|
||||
/// Flutter icons FlashMailIcons
|
||||
/// Copyright (C) 2023 by original authors @ fluttericon.com, fontello.com
|
||||
/// This font was generated by FlutterIcon.com, which is derived from Fontello.
|
||||
///
|
||||
/// To use this font, place it in your fonts/ directory and include the
|
||||
/// following in your pubspec.yaml
|
||||
///
|
||||
/// flutter:
|
||||
/// fonts:
|
||||
/// - family: FlashMailIcons
|
||||
/// fonts:
|
||||
/// - asset: fonts/FlashMailIcons.ttf
|
||||
//
|
||||
// Flutter icons FlashMailIcons
|
||||
// Copyright (C) 2023 by original authors @ fluttericon.com, fontello.com
|
||||
// This font was generated by FlutterIcon.com, which is derived from Fontello.
|
||||
//
|
||||
// To use this font, place it in your fonts/ directory and include the
|
||||
// following in your pubspec.yaml
|
||||
//
|
||||
// flutter:
|
||||
// fonts:
|
||||
// - family: FlashMailIcons
|
||||
// fonts:
|
||||
// - asset: fonts/FlashMailIcons.ttf
|
||||
|
||||
import "package:flutter/widgets.dart";
|
||||
|
||||
|
|
|
|||
15
lib/shared/utils/avatar_color.dart
Normal file
15
lib/shared/utils/avatar_color.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
/// A deterministic color for [key] (e.g. a contact address), picked from
|
||||
/// [palette] — same contact always gets the same avatar color, and every
|
||||
/// choice comes from the app's own palette instead of arbitrary RGB space.
|
||||
Color colorForKey(String key, List<Color> palette) {
|
||||
if (palette.isEmpty) return Colors.grey;
|
||||
final hash = key.codeUnits.fold<int>(0, (acc, c) => (acc * 31 + c) & 0x7fffffff);
|
||||
return palette[hash % palette.length];
|
||||
}
|
||||
|
||||
/// Black or white, whichever reads clearly on [background].
|
||||
Color contrastingTextColor(Color background) {
|
||||
return background.computeLuminance() > 0.5 ? Colors.black : Colors.white;
|
||||
}
|
||||
37
lib/shared/utils/format.dart
Normal file
37
lib/shared/utils/format.dart
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "package:intl/intl.dart";
|
||||
|
||||
/// One path for all replies so the Re: rule lives once.
|
||||
String replySubject(String subject) =>
|
||||
subject.isEmpty || subject.startsWith("Re:") ? subject : "Re: $subject";
|
||||
|
||||
String formatTime(int? seconds) {
|
||||
if (seconds == null) return "";
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true);
|
||||
return DateFormat.yMMMMd().add_jm().format(date.toLocal());
|
||||
}
|
||||
|
||||
/// The full date is too wide for a list row's trailing slot — this is what
|
||||
/// message_tile.dart shows instead: just a time for today, else a short date.
|
||||
String formatShortTime(int? seconds) {
|
||||
if (seconds == null) return "";
|
||||
final date =
|
||||
DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true).toLocal();
|
||||
final now = DateTime.now();
|
||||
if (date.year == now.year && date.month == now.month && date.day == now.day) {
|
||||
return DateFormat.jm().format(date);
|
||||
}
|
||||
if (date.year == now.year) {
|
||||
return DateFormat.MMMd().format(date);
|
||||
}
|
||||
return DateFormat.yMd().format(date);
|
||||
}
|
||||
|
||||
Future<void> copyText(BuildContext context, String text) async {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(const SnackBar(content: Text("Copied to clipboard")));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import "dart:math";
|
||||
import "dart:ui";
|
||||
|
||||
Color getRandomColor() {
|
||||
Random random = Random();
|
||||
return Color.fromRGBO(
|
||||
random.nextInt(256),
|
||||
random.nextInt(256),
|
||||
random.nextInt(256),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import "dart:math";
|
||||
|
||||
String randomString(int length) {
|
||||
const charset =
|
||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
final random = Random.secure();
|
||||
final codeUnits = List<int>.generate(
|
||||
length,
|
||||
(index) => charset.codeUnitAt(random.nextInt(charset.length)),
|
||||
);
|
||||
return String.fromCharCodes(codeUnits);
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
final RegExp emailRegex = RegExp(
|
||||
r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$',
|
||||
);
|
||||
15
lib/shared/utils/snackbar.dart
Normal file
15
lib/shared/utils/snackbar.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
/// Errors get the error color explicitly — the theme's default SnackBar is
|
||||
/// neutral, so a plain SnackBar(content: ...) is *not* red anymore.
|
||||
void showErrorSnackBar(BuildContext context, String message,
|
||||
{Duration? duration}) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: scheme.error,
|
||||
duration: duration ?? const Duration(seconds: 4),
|
||||
content: Text(message, style: TextStyle(color: scheme.onError)),
|
||||
),
|
||||
);
|
||||
}
|
||||
426
lib/smol/client.dart
Normal file
426
lib/smol/client.dart
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
// App-level client: the flows of gsmol's app.js — connect with pinning, fetch
|
||||
// with verification and acknowledgment, send with sent copies, contacts and
|
||||
// rotation — on top of the pure protocol modules.
|
||||
|
||||
import "dart:convert";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:smol_mail/smol/crypto.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/proto.dart";
|
||||
import "package:smol_mail/smol/store.dart";
|
||||
import "package:smol_mail/smol/transport.dart";
|
||||
|
||||
class RefreshOutcome {
|
||||
final String message;
|
||||
final bool warn;
|
||||
|
||||
const RefreshOutcome(this.message, this.warn);
|
||||
}
|
||||
|
||||
class FetchSummary {
|
||||
final int stored;
|
||||
final List<String> rejected;
|
||||
|
||||
const FetchSummary(this.stored, this.rejected);
|
||||
}
|
||||
|
||||
class OpenedRecord {
|
||||
final String id;
|
||||
final Uint8List? sender;
|
||||
final int? time;
|
||||
final Map<String, String> fields;
|
||||
final String body;
|
||||
final String? error;
|
||||
|
||||
const OpenedRecord(this.id,
|
||||
{this.sender, this.time, this.fields = const {}, this.body = "", this.error});
|
||||
|
||||
String get subject => error == null ? (fields["Subject"] ?? "") : "";
|
||||
}
|
||||
|
||||
class SmolClient {
|
||||
final SmolStore store;
|
||||
|
||||
/// Trust warnings (§4/§8) the UI must not let the user miss; the app wires
|
||||
/// this to a persistent banner in app_widget.dart.
|
||||
void Function(String message)? onWarning;
|
||||
|
||||
SmolClient(this.store);
|
||||
|
||||
void _warn(String message) => onWarning?.call(message);
|
||||
|
||||
// Opening an envelope costs an X25519 agreement and an Ed25519
|
||||
// verification, and an envelope's plaintext never changes — so the result
|
||||
// is kept. Failures are cached too, so one bad message is not retried on
|
||||
// every render. Rotation clears it, since the key set grew.
|
||||
final _openedCache = <String, OpenedRecord>{};
|
||||
|
||||
SmolIdentity? get identity => store.identity();
|
||||
|
||||
SmolAddress? accountAddress() {
|
||||
final account = store.account();
|
||||
if (account == null) return null;
|
||||
final suffix = account.port == defaultPort ? "" : ":${account.port}";
|
||||
return parseAddress("${account.user}@${account.host}$suffix");
|
||||
}
|
||||
|
||||
// §4: registration and fetching demand a pinned key; sending to a recipient
|
||||
// whose key we already hold tolerates an unpinned server.
|
||||
Future<OpenedSession> connect(SmolAddress addr,
|
||||
{required bool requirePin}) async {
|
||||
final pinned = store.serverPin(addr.host);
|
||||
if (requirePin && pinned == null) {
|
||||
throw SmolError("no pinned key for ${addr.host}. Obtain it from the "
|
||||
"operator through a trusted channel, then pin it in settings.");
|
||||
}
|
||||
final wire = await TcpWire.connect(addr.host, addr.port);
|
||||
try {
|
||||
final opened = await openSession(wire, addr.host, pinned: pinned);
|
||||
if (pinned == null) {
|
||||
_warn("${addr.host} is not pinned; its key is "
|
||||
"${b32encode(opened.serverStatic)}.\n"
|
||||
"RESOLVE results from this session are UNVERIFIED (SPEC.md §8).");
|
||||
}
|
||||
return opened;
|
||||
} catch (_) {
|
||||
wire.close();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// --- identity setup ------------------------------------------------------------
|
||||
|
||||
SmolIdentity createIdentity() {
|
||||
final fresh = newIdentity();
|
||||
store.setIdentity(fresh.seed);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
SmolIdentity restoreIdentity(String seedHex) {
|
||||
Uint8List seed;
|
||||
try {
|
||||
seed = unhex(seedHex.trim());
|
||||
} on Exception {
|
||||
throw const SmolError("seed must be 64 hex characters");
|
||||
}
|
||||
if (seed.length != keyLen) {
|
||||
throw SmolError("seed is ${seed.length} bytes, expected $keyLen");
|
||||
}
|
||||
final restored = identityFromSeed(seed);
|
||||
store.setIdentity(seed);
|
||||
return restored;
|
||||
}
|
||||
|
||||
void pinServer(String host, String keyB32) {
|
||||
final key = b32decode(keyB32);
|
||||
if (key.length != keyLen) {
|
||||
throw SmolError("server key is ${key.length} bytes, expected $keyLen");
|
||||
}
|
||||
store.pinServer(host.trim().toLowerCase(), key);
|
||||
}
|
||||
|
||||
Future<void> registerAccount(String addressText, {String token = ""}) async {
|
||||
final me = identity;
|
||||
if (me == null) throw const SmolError("no identity yet");
|
||||
final addr = parseAddress(addressText);
|
||||
final opened = await connect(addr, requirePin: true);
|
||||
try {
|
||||
await registerOp(opened.session, addr.user, me,
|
||||
RegisterOptions(token: token));
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
}
|
||||
store.setAccount(addr);
|
||||
}
|
||||
|
||||
/// Restoring a seed brings back the identity, not the memory of what
|
||||
/// address a *different device* registered it under — "account" is
|
||||
/// local-only state, never asked of the server. This binds it without
|
||||
/// REGISTER: RESOLVE the address and require it name this exact key, so a
|
||||
/// typo or someone else's address cannot misfile fetch and Reply-To.
|
||||
Future<SmolAddress> recallAccount(String addressText) async {
|
||||
final me = identity;
|
||||
if (me == null) throw const SmolError("no identity yet");
|
||||
final addr = parseAddress(addressText);
|
||||
final opened = await connect(addr, requirePin: true);
|
||||
Uint8List current;
|
||||
try {
|
||||
current = (await resolveOp(opened.session, addr.user)).identity;
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
}
|
||||
if (!timingSafeEqual(current, me.publicKey)) {
|
||||
throw SmolError(
|
||||
"${addr.short} resolves to a different key — not this identity");
|
||||
}
|
||||
store.setAccount(addr);
|
||||
return addr;
|
||||
}
|
||||
|
||||
// --- fetch ----------------------------------------------------------------------
|
||||
|
||||
Future<FetchSummary> fetch() async {
|
||||
final me = identity;
|
||||
final addr = accountAddress();
|
||||
if (me == null) throw const SmolError("no identity yet");
|
||||
if (addr == null) {
|
||||
throw const SmolError("not registered; register an address first");
|
||||
}
|
||||
var stored = 0;
|
||||
final rejected = <String>[];
|
||||
final opened = await connect(addr, requirePin: true);
|
||||
try {
|
||||
await authenticate(opened.session, opened.handshakeHash, addr.user, me);
|
||||
while (true) {
|
||||
final records = await fetchOp(opened.session);
|
||||
if (records.isEmpty) break;
|
||||
final acked = <Uint8List>[];
|
||||
for (final record in records) {
|
||||
try {
|
||||
if (!timingSafeEqual(messageId(record.envelope), record.id)) {
|
||||
throw const SmolError("id does not match the envelope");
|
||||
}
|
||||
unseal(store.identities(), record.envelope);
|
||||
} on SmolError catch (err) {
|
||||
// Left on the server rather than destroyed, so a client-side bug
|
||||
// cannot lose mail.
|
||||
rejected.add("${hex(record.id)}: ${err.message}");
|
||||
continue;
|
||||
}
|
||||
final fresh = await store.storeIfNew("inbox", MailRecord(hex(record.id), record.envelope,
|
||||
receivedAt: record.receivedAt));
|
||||
if (fresh != null) stored++;
|
||||
acked.add(record.id);
|
||||
}
|
||||
if (acked.isEmpty) break;
|
||||
await deleteOp(opened.session, acked);
|
||||
}
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
}
|
||||
return FetchSummary(stored, rejected);
|
||||
}
|
||||
|
||||
// --- compose and send -----------------------------------------------------------
|
||||
|
||||
// Prefer a key we already trust; fall back to RESOLVE with trust on first
|
||||
// use.
|
||||
Future<Uint8List> resolveRecipient(SmolAddress addr) async {
|
||||
if (addr.identity != null) {
|
||||
store.saveContact(addr.short, addr.identity!, true);
|
||||
return addr.identity!;
|
||||
}
|
||||
final known = store.contact(addr.short);
|
||||
if (known != null) return known.key;
|
||||
final opened = await connect(addr, requirePin: false);
|
||||
Uint8List current;
|
||||
try {
|
||||
current = (await resolveOp(opened.session, addr.user)).identity;
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
}
|
||||
store.saveContact(addr.short, current, false);
|
||||
return current;
|
||||
}
|
||||
|
||||
Future<String> send(String toText, String subject, String body,
|
||||
{String? replyTo, bool anonymous = false}) async {
|
||||
final me = identity;
|
||||
if (me == null) throw const SmolError("no identity yet");
|
||||
final addr = parseAddress(toText);
|
||||
final recipient = await resolveRecipient(addr);
|
||||
final account = accountAddress();
|
||||
final fields = <String, String>{"Subject": subject, "In-Reply-To": replyTo ?? ""};
|
||||
// A signed Reply-To lets a first-time recipient name and answer us
|
||||
// (§5.5 allows unknown keys); "anonymous" omits it.
|
||||
if (account != null && !anonymous) {
|
||||
fields["Reply-To"] = account.uri(me.publicKey);
|
||||
}
|
||||
final bodyBytes = utf8Bytes(buildFrontmatter(
|
||||
fields, "${body.replaceFirst(RegExp(r"\s+$"), "")}\n"));
|
||||
final envelope = seal(me, recipient, bodyBytes);
|
||||
final opened = await connect(addr, requirePin: false);
|
||||
try {
|
||||
await sendOp(opened.session, envelope);
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
}
|
||||
// §5.6: the ephemeral is gone, so keep a copy sealed to ourselves.
|
||||
await store.storeMessage("sent", MailRecord(hex(messageId(envelope)),
|
||||
seal(me, me.publicKey, bodyBytes),
|
||||
recipient: addr.short, sentAt: nowSeconds()));
|
||||
return addr.short;
|
||||
}
|
||||
|
||||
// --- reading -----------------------------------------------------------------
|
||||
|
||||
// What is known about a sender changes as the user binds addresses to keys,
|
||||
// so this layer sits over the cached envelope and is recomputed per call —
|
||||
// it is a map lookup, not crypto.
|
||||
OpenedRecord describe(MailRecord row) {
|
||||
final opened = _openEnvelope(row);
|
||||
if (opened.error != null) return opened;
|
||||
return OpenedRecord(
|
||||
row.id,
|
||||
sender: opened.sender,
|
||||
time: opened.time,
|
||||
fields: opened.fields,
|
||||
body: opened.body,
|
||||
);
|
||||
}
|
||||
|
||||
OpenedRecord _openEnvelope(MailRecord row) {
|
||||
var entry = _openedCache[row.id];
|
||||
if (entry == null) {
|
||||
try {
|
||||
final opened = unseal(store.identities(), row.envelope);
|
||||
final parsed = parseFrontmatter(utf8.decode(opened.body, allowMalformed: true));
|
||||
entry = OpenedRecord(row.id,
|
||||
sender: opened.sender,
|
||||
time: opened.time,
|
||||
fields: parsed.fields,
|
||||
body: parsed.body);
|
||||
} on SmolError catch (err) {
|
||||
entry = OpenedRecord(row.id, error: err.message);
|
||||
}
|
||||
_openedCache[row.id] = entry;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// The Reply-To address carried inside the message, but only when it is a
|
||||
/// full smol:// URI whose key matches the signer (§5.7); anything else is
|
||||
/// ordinary text.
|
||||
SmolAddress? replyAddress(OpenedRecord opened) {
|
||||
final claim = opened.fields["Reply-To"];
|
||||
if (claim == null || opened.sender == null) return null;
|
||||
try {
|
||||
final parsed = parseAddress(claim);
|
||||
if (parsed.identity != null &&
|
||||
timingSafeEqual(parsed.identity!, opened.sender!)) {
|
||||
return parsed;
|
||||
}
|
||||
} on SmolError {
|
||||
// malformed claim: display, never bind
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Bind a user-supplied address to the key that signed a message. A smol://
|
||||
/// address carries its own key (verified); a short address is resolved and
|
||||
/// the result kept on first use. Anything that binds a different key is
|
||||
/// refused.
|
||||
Future<void> nameSender(String text, Uint8List senderKey) async {
|
||||
final addr = parseAddress(text.trim());
|
||||
Uint8List key;
|
||||
var verified = true;
|
||||
if (addr.identity == null) {
|
||||
final opened = await connect(addr, requirePin: false);
|
||||
try {
|
||||
key = (await resolveOp(opened.session, addr.user)).identity;
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
}
|
||||
verified = false; // trust on first use, as with any RESOLVE
|
||||
} else {
|
||||
key = addr.identity!;
|
||||
}
|
||||
if (!timingSafeEqual(key, senderKey)) {
|
||||
throw const SmolError(
|
||||
"that address carries a different key than this message's sender");
|
||||
}
|
||||
store.saveContact(addr.short, senderKey, verified);
|
||||
}
|
||||
|
||||
/// A signed Reply-To is the sender's own claim, so it saves as verified —
|
||||
/// but never over an address already pinned to a different key (§8: a key
|
||||
/// change without a rotation chain needs out-of-band confirmation).
|
||||
Future<void> saveReplyAddress(SmolAddress addr, Uint8List senderKey) async {
|
||||
final existing = store.contact(addr.short);
|
||||
if (existing != null && !timingSafeEqual(existing.key, senderKey)) {
|
||||
throw SmolError("${addr.short} is already known with a different key — "
|
||||
"verify out of band before replying");
|
||||
}
|
||||
store.saveContact(addr.short, senderKey, true);
|
||||
}
|
||||
|
||||
// Re-resolve a contact and apply §8: a valid rotation chain is accepted and
|
||||
// surfaced; anything else requires out-of-band verification.
|
||||
Future<RefreshOutcome> refreshContact(String address) async {
|
||||
final addr = parseAddress(address);
|
||||
if (addr.identity != null) {
|
||||
throw const SmolError("that address already carries a key; use import instead");
|
||||
}
|
||||
final known = store.contact(addr.short);
|
||||
final opened = await connect(addr, requirePin: false);
|
||||
Resolved resolved;
|
||||
try {
|
||||
resolved = await resolveOp(opened.session, addr.user);
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
}
|
||||
if (known == null) {
|
||||
store.saveContact(addr.short, resolved.identity, false);
|
||||
return RefreshOutcome(
|
||||
"${addr.short} pinned (trust on first use"
|
||||
"${opened.pinned ? "" : ", UNVERIFIED server"})",
|
||||
!opened.pinned);
|
||||
}
|
||||
if (timingSafeEqual(known.key, resolved.identity)) {
|
||||
return RefreshOutcome("${addr.short}: key unchanged", false);
|
||||
}
|
||||
if (walkChain(known.key, resolved.identity, resolved.chain)) {
|
||||
store.saveContact(addr.short, resolved.identity, known.verified);
|
||||
return RefreshOutcome(
|
||||
"${addr.short} rotated its key; a signed chain confirms it.\n"
|
||||
"now ${b32encode(resolved.identity)}",
|
||||
true);
|
||||
}
|
||||
return RefreshOutcome(
|
||||
"${addr.short} presents a different key with no valid rotation chain.\n"
|
||||
"Verify out of band, then import the new smol:// address.",
|
||||
true);
|
||||
}
|
||||
|
||||
// Bind a smol:// address to the key it carries (§8's strong path); the
|
||||
// displaced key, if any, lands in the contact's history.
|
||||
void importContact(String text) {
|
||||
final addr = parseAddress(text.trim());
|
||||
if (addr.identity == null) {
|
||||
throw const SmolError("import needs a smol:// address carrying a key");
|
||||
}
|
||||
store.saveContact(addr.short, addr.identity!, true);
|
||||
}
|
||||
|
||||
// --- rotation -----------------------------------------------------------------
|
||||
|
||||
// §7: rotate to a fresh seed and rebind the account with a signed
|
||||
// certificate. The old seed is kept by the store, since mail sealed to it
|
||||
// stays readable with nothing else.
|
||||
Future<SmolIdentity> rotateIdentity() async {
|
||||
final me = identity;
|
||||
final addr = accountAddress();
|
||||
if (me == null || addr == null) {
|
||||
throw const SmolError("rotate needs a registered account");
|
||||
}
|
||||
final fresh = newIdentity();
|
||||
final cert = makeCert(me, fresh.seed);
|
||||
final opened = await connect(addr, requirePin: true);
|
||||
try {
|
||||
await registerOp(opened.session, addr.user, fresh,
|
||||
RegisterOptions(cert: cert));
|
||||
} finally {
|
||||
opened.session.wire.close();
|
||||
}
|
||||
store.rotateIdentity(fresh.seed);
|
||||
_openedCache.clear();
|
||||
return fresh;
|
||||
}
|
||||
|
||||
// A full wipe: every secret and every stored envelope. The UI must confirm.
|
||||
Future<void> wipe() async {
|
||||
await store.wipe();
|
||||
_openedCache.clear();
|
||||
}
|
||||
}
|
||||
26
lib/smol/config.dart
Normal file
26
lib/smol/config.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import "package:smol_mail/smol/proto.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/store.dart";
|
||||
|
||||
// Deploy-time configuration, empty by default (mirrors gsmol's config.js).
|
||||
// A release may bake in a server key with:
|
||||
// flutter build apk --dart-define=SMOL_PRESET_SERVER=example.org \
|
||||
// --dart-define=SMOL_PRESET_SERVER_KEY=base32key
|
||||
// It only makes sense when whoever ships this app and whoever runs that
|
||||
// smolmaild are the same trusted party: the value arrives with the app
|
||||
// itself, which is the trusted channel SPEC.md §4 asks a pin to come from.
|
||||
// This only seeds the first run — once written it is an ordinary pin,
|
||||
// removable in settings like any other, and never overwrites a host the user
|
||||
// (or a previous install) already pinned.
|
||||
const _presetHost = String.fromEnvironment("SMOL_PRESET_SERVER");
|
||||
const _presetKey = String.fromEnvironment("SMOL_PRESET_SERVER_KEY");
|
||||
|
||||
void applyPresetServer(SmolStore store) {
|
||||
if (_presetHost.isEmpty || _presetKey.isEmpty) return;
|
||||
if (store.serverPin(_presetHost) != null) return;
|
||||
try {
|
||||
store.pinServer(_presetHost, b32decode(_presetKey));
|
||||
} on SmolError {
|
||||
// malformed preset: leave unpinned rather than block boot
|
||||
}
|
||||
}
|
||||
435
lib/smol/crypto.dart
Normal file
435
lib/smol/crypto.dart
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
// Smol Mail primitives (SPEC.md §1): SHA-2, HMAC/HKDF-SHA256, ChaCha20-Poly1305,
|
||||
// X25519, Ed25519, and the §2 key conversions between the two curves. Pure
|
||||
// Dart rather than PointyCastle so the byte-exact vectors from the reference
|
||||
// client (test/vectors.json) can pin every operation.
|
||||
|
||||
import "dart:convert";
|
||||
import "dart:math";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:crypto/crypto.dart" as hashes;
|
||||
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
|
||||
// --- bytes --------------------------------------------------------------------
|
||||
|
||||
Uint8List concat(List<List<int>> parts) {
|
||||
final out = Uint8List(parts.fold(0, (n, p) => n + p.length));
|
||||
var off = 0;
|
||||
for (final p in parts) {
|
||||
out.setRange(off, off + p.length, p);
|
||||
off += p.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Uint8List utf8Bytes(String text) => Uint8List.fromList(utf8.encode(text));
|
||||
|
||||
String hex(List<int> bytes) =>
|
||||
bytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join();
|
||||
|
||||
Uint8List unhex(String text) {
|
||||
if (text.length.isOdd) throw ArgumentError("odd-length hex string: $text");
|
||||
final out = Uint8List(text.length ~/ 2);
|
||||
for (var i = 0; i < out.length; i++) {
|
||||
out[i] = int.parse(text.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
BigInt leBytesToBigInt(Uint8List bytes) {
|
||||
var n = BigInt.zero;
|
||||
for (var i = bytes.length - 1; i >= 0; i--) {
|
||||
n = (n << 8) | BigInt.from(bytes[i]);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
Uint8List bigIntToLeBytes(BigInt value, int length) {
|
||||
final out = Uint8List(length);
|
||||
var v = value;
|
||||
for (var i = 0; i < length; i++) {
|
||||
out[i] = (v & BigInt.from(0xff)).toInt();
|
||||
v >>= 8;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Uint8List randomBytes(int n) {
|
||||
final out = Uint8List(n);
|
||||
final rng = Random.secure();
|
||||
for (var i = 0; i < n; i++) {
|
||||
out[i] = rng.nextInt(256);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool timingSafeEqual(List<int> a, List<int> b) {
|
||||
if (a.length != b.length) return false;
|
||||
var diff = 0;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
diff |= a[i] ^ b[i];
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
// --- SHA-256 / SHA-512 / HMAC-SHA256 / HKDF (RFC 2104, RFC 5869) ---------------
|
||||
|
||||
Uint8List sha256(List<int> message) =>
|
||||
Uint8List.fromList(hashes.sha256.convert(message).bytes);
|
||||
|
||||
Uint8List sha512(List<int> message) =>
|
||||
Uint8List.fromList(hashes.sha512.convert(message).bytes);
|
||||
|
||||
Uint8List hmacSha256(List<int> key, List<int> message) =>
|
||||
Uint8List.fromList(hashes.Hmac(hashes.sha256, key).convert(message).bytes);
|
||||
|
||||
Uint8List hkdfSha256(List<int> ikm, List<int> salt, List<int> info,
|
||||
[int length = 32]) {
|
||||
final prk = hmacSha256(salt, ikm);
|
||||
var out = <int>[];
|
||||
var block = <int>[];
|
||||
var counter = 1;
|
||||
while (out.length < length) {
|
||||
block = hmacSha256(prk, concat([block, info, [counter]]));
|
||||
out.addAll(block);
|
||||
counter++;
|
||||
}
|
||||
return Uint8List.fromList(out.sublist(0, length));
|
||||
}
|
||||
|
||||
// --- ChaCha20-Poly1305 AEAD (RFC 8439) -----------------------------------------
|
||||
|
||||
const _mask32 = 0xFFFFFFFF;
|
||||
|
||||
int _rotl32(int x, int n) => ((x << n) | (x >>> (32 - n))) & _mask32;
|
||||
|
||||
Uint8List _chachaBlock(Uint8List key, int counter, Uint8List nonce) {
|
||||
final state = Uint32List(16);
|
||||
state.setAll(0, [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]);
|
||||
final kview = ByteData.view(key.buffer, key.offsetInBytes, key.length);
|
||||
for (var i = 0; i < 8; i++) {
|
||||
state[4 + i] = kview.getUint32(i * 4, Endian.little);
|
||||
}
|
||||
state[12] = counter & _mask32;
|
||||
final nview = ByteData.view(nonce.buffer, nonce.offsetInBytes, nonce.length);
|
||||
for (var i = 0; i < 3; i++) {
|
||||
state[13 + i] = nview.getUint32(i * 4, Endian.little);
|
||||
}
|
||||
final x = Uint32List.fromList(state);
|
||||
void qr(int a, int b, int c, int d) {
|
||||
x[a] = (x[a] + x[b]) & _mask32;
|
||||
x[d] = _rotl32(x[d] ^ x[a], 16);
|
||||
x[c] = (x[c] + x[d]) & _mask32;
|
||||
x[b] = _rotl32(x[b] ^ x[c], 12);
|
||||
x[a] = (x[a] + x[b]) & _mask32;
|
||||
x[d] = _rotl32(x[d] ^ x[a], 8);
|
||||
x[c] = (x[c] + x[d]) & _mask32;
|
||||
x[b] = _rotl32(x[b] ^ x[c], 7);
|
||||
}
|
||||
|
||||
for (var i = 0; i < 10; i++) {
|
||||
qr(0, 4, 8, 12);
|
||||
qr(1, 5, 9, 13);
|
||||
qr(2, 6, 10, 14);
|
||||
qr(3, 7, 11, 15);
|
||||
qr(0, 5, 10, 15);
|
||||
qr(1, 6, 11, 12);
|
||||
qr(2, 7, 8, 13);
|
||||
qr(3, 4, 9, 14);
|
||||
}
|
||||
final out = Uint8List(64);
|
||||
final view = ByteData.view(out.buffer);
|
||||
for (var i = 0; i < 16; i++) {
|
||||
view.setUint32(i * 4, (x[i] + state[i]) & _mask32, Endian.little);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Uint8List _chacha20Xor(Uint8List key, int counter, Uint8List nonce, Uint8List data) {
|
||||
final out = Uint8List(data.length);
|
||||
for (var off = 0; off < data.length; off += 64) {
|
||||
final stream = _chachaBlock(key, counter + off ~/ 64, nonce);
|
||||
final n = min(64, data.length - off);
|
||||
for (var i = 0; i < n; i++) {
|
||||
out[off + i] = data[off + i] ^ stream[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Poly1305 over BigInt; correctness over speed, messages here stay small.
|
||||
Uint8List _poly1305(Uint8List key, List<int> message) {
|
||||
final p = (BigInt.one << 130) - BigInt.from(5);
|
||||
final r = leBytesToBigInt(key.sublist(0, 16)) &
|
||||
BigInt.parse("0x0ffffffc0ffffffc0ffffffc0fffffff");
|
||||
final s = leBytesToBigInt(key.sublist(16, 32));
|
||||
var acc = BigInt.zero;
|
||||
for (var off = 0; off < message.length; off += 16) {
|
||||
final block = message.sublist(off, min(off + 16, message.length));
|
||||
acc = (acc + leBytesToBigInt(Uint8List.fromList(block)) +
|
||||
(BigInt.one << (8 * block.length))) *
|
||||
r %
|
||||
p;
|
||||
}
|
||||
return bigIntToLeBytes((acc + s) & ((BigInt.one << 128) - BigInt.one), 16);
|
||||
}
|
||||
|
||||
Uint8List _pad16(int n) => Uint8List((16 - (n % 16)) % 16);
|
||||
|
||||
Uint8List _le64(int n) => bigIntToLeBytes(BigInt.from(n), 8);
|
||||
|
||||
Uint8List aeadEncrypt(Uint8List key, Uint8List nonce, Uint8List plaintext,
|
||||
Uint8List aad) {
|
||||
final polyKey = _chachaBlock(key, 0, nonce).sublist(0, 32);
|
||||
final ciphertext = _chacha20Xor(key, 1, nonce, plaintext);
|
||||
final mac = _poly1305(polyKey,
|
||||
concat([aad, _pad16(aad.length), ciphertext, _pad16(ciphertext.length), _le64(aad.length), _le64(ciphertext.length)]));
|
||||
return concat([ciphertext, mac]);
|
||||
}
|
||||
|
||||
Uint8List aeadDecrypt(Uint8List key, Uint8List nonce, Uint8List sealed, Uint8List aad) {
|
||||
if (sealed.length < 16) {
|
||||
throw const SmolError("ciphertext shorter than the Poly1305 tag");
|
||||
}
|
||||
final ciphertext = sealed.sublist(0, sealed.length - 16);
|
||||
final polyKey = _chachaBlock(key, 0, nonce).sublist(0, 32);
|
||||
final expect = _poly1305(polyKey,
|
||||
concat([aad, _pad16(aad.length), ciphertext, _pad16(ciphertext.length), _le64(aad.length), _le64(ciphertext.length)]));
|
||||
if (!timingSafeEqual(expect, sealed.sublist(sealed.length - 16))) {
|
||||
throw const SmolError("decryption failed: bad Poly1305 tag");
|
||||
}
|
||||
return _chacha20Xor(key, 1, nonce, ciphertext);
|
||||
}
|
||||
|
||||
// --- X25519 (RFC 7748) ---------------------------------------------------------
|
||||
|
||||
final BigInt _p = (BigInt.one << 255) - BigInt.from(19);
|
||||
final BigInt _mask255 = (BigInt.one << 255) - BigInt.one;
|
||||
|
||||
BigInt _mod(BigInt value, [BigInt? p]) {
|
||||
final m = p ?? _p;
|
||||
return ((value % m) + m) % m;
|
||||
}
|
||||
|
||||
BigInt _powMod(BigInt base, BigInt exponent, [BigInt? p]) {
|
||||
final m = p ?? _p;
|
||||
var out = BigInt.one;
|
||||
base = _mod(base, m);
|
||||
while (exponent > BigInt.zero) {
|
||||
if (exponent & BigInt.one == BigInt.one) out = out * base % m;
|
||||
base = base * base % m;
|
||||
exponent >>= 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Uint8List clampScalar(Uint8List scalar) {
|
||||
final k = Uint8List.fromList(scalar);
|
||||
k[0] &= 248;
|
||||
k[31] &= 127;
|
||||
k[31] |= 64;
|
||||
return k;
|
||||
}
|
||||
|
||||
BigInt _x25519Raw(Uint8List scalar, Uint8List u) {
|
||||
final k = leBytesToBigInt(clampScalar(scalar));
|
||||
final x1 = leBytesToBigInt(u) & _mask255;
|
||||
const a24 = 121665;
|
||||
var x2 = BigInt.one, z2 = BigInt.zero, x3 = x1, z3 = BigInt.one;
|
||||
var swap = BigInt.zero;
|
||||
for (var t = 254; t >= 0; t--) {
|
||||
final kt = (k >> t) & BigInt.one;
|
||||
swap ^= kt;
|
||||
if (swap == BigInt.one) {
|
||||
var tmp = x2;
|
||||
x2 = x3;
|
||||
x3 = tmp;
|
||||
tmp = z2;
|
||||
z2 = z3;
|
||||
z3 = tmp;
|
||||
}
|
||||
swap = kt;
|
||||
final a = _mod(x2 + z2), aa = a * a % _p;
|
||||
final b = _mod(x2 - z2), bb = b * b % _p;
|
||||
final e = _mod(aa - bb);
|
||||
final c = _mod(x3 + z3), d = _mod(x3 - z3);
|
||||
final da = d * a % _p, cb = c * b % _p;
|
||||
final sum = _mod(da + cb), diff = _mod(da - cb);
|
||||
x3 = sum * sum % _p;
|
||||
z3 = x1 * diff * diff % _p;
|
||||
x2 = aa * bb % _p;
|
||||
z2 = e * _mod(aa + BigInt.from(a24) * e) % _p;
|
||||
}
|
||||
if (swap == BigInt.one) {
|
||||
var tmp = x2;
|
||||
x2 = x3;
|
||||
x3 = tmp;
|
||||
tmp = z2;
|
||||
z2 = z3;
|
||||
z3 = tmp;
|
||||
}
|
||||
return x2 * _powMod(z2, _p - BigInt.two) % _p;
|
||||
}
|
||||
|
||||
// §2's low-order rejection: a clamped scalar is a multiple of 8, so any
|
||||
// low-order peer point yields an all-zero shared secret — rejecting the zero
|
||||
// output rejects all of them.
|
||||
Uint8List x25519(Uint8List scalar, Uint8List peerPublic) {
|
||||
final shared = bigIntToLeBytes(_x25519Raw(scalar, peerPublic), 32);
|
||||
if (shared.every((b) => b == 0)) {
|
||||
throw const SmolError("rejected low-order key agreement point");
|
||||
}
|
||||
return shared;
|
||||
}
|
||||
|
||||
Uint8List x25519Base(Uint8List scalar) => bigIntToLeBytes(
|
||||
_x25519Raw(scalar, unhex("0900000000000000000000000000000000000000000000000000000000000000")),
|
||||
32);
|
||||
|
||||
// --- Ed25519 (RFC 8032) --------------------------------------------------------
|
||||
|
||||
final BigInt _l = (BigInt.one << 252) +
|
||||
BigInt.parse("27742317777372353535851937790883648493");
|
||||
final BigInt _d = _mod(-BigInt.from(121665) * _powMod(BigInt.from(121666), _p - BigInt.two));
|
||||
final _Point _b = _Point.fromAffine(
|
||||
BigInt.parse(
|
||||
"15112221349535400772501151409588531511454012693041857206046113283949847762202"),
|
||||
_mod(BigInt.from(4) * _powMod(BigInt.from(5), _p - BigInt.two)));
|
||||
|
||||
class _Point {
|
||||
final BigInt x, y, z, t;
|
||||
|
||||
const _Point(this.x, this.y, this.z, this.t);
|
||||
|
||||
_Point.fromAffine(BigInt x, BigInt y)
|
||||
: this(x, y, BigInt.one, _mod(x * y));
|
||||
}
|
||||
|
||||
final _Point _identity = _Point(
|
||||
BigInt.zero, BigInt.one, BigInt.one, BigInt.zero);
|
||||
|
||||
_Point _pointAdd(_Point p, _Point q) {
|
||||
final a = _mod(p.y - p.x) * _mod(q.y - q.x) % _p;
|
||||
final b = _mod(p.y + p.x) * _mod(q.y + q.x) % _p;
|
||||
final c = BigInt.two * p.t * q.t % _p * _d % _p;
|
||||
final d = BigInt.two * p.z * q.z % _p;
|
||||
final e = _mod(b - a), f = _mod(d - c), g = _mod(d + c);
|
||||
final h = b + a;
|
||||
return _Point(e * f % _p, g * h % _p, f * g % _p, e * h % _p);
|
||||
}
|
||||
|
||||
_Point _pointDouble(_Point p) {
|
||||
final a = p.x * p.x % _p;
|
||||
final b = p.y * p.y % _p;
|
||||
final c = BigInt.two * p.z * p.z % _p;
|
||||
final d = _p - a; // a = -1 on this curve, so d = -A
|
||||
final e = _mod(_mod(p.x + p.y) * _mod(p.x + p.y) - a - b);
|
||||
final g = _mod(d + b);
|
||||
final f = _mod(g - c);
|
||||
final h = _mod(d - b);
|
||||
return _Point(e * f % _p, g * h % _p, f * g % _p, e * h % _p);
|
||||
}
|
||||
|
||||
_Point _scalarMult(BigInt scalar, _Point point) {
|
||||
var result = _identity;
|
||||
for (var t = 254; t >= 0; t--) {
|
||||
result = _pointDouble(result);
|
||||
if ((scalar >> t) & BigInt.one == BigInt.one) result = _pointAdd(result, point);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Uint8List _encodePoint(_Point p) {
|
||||
final zInv = _powMod(p.z, _p - BigInt.two);
|
||||
final x = p.x * zInv % _p, y = p.y * zInv % _p;
|
||||
final out = bigIntToLeBytes(y, 32);
|
||||
out[31] |= (x & BigInt.one).toInt() << 7;
|
||||
return out;
|
||||
}
|
||||
|
||||
_Point _decodePoint(Uint8List bytes) {
|
||||
if (bytes.length != 32) {
|
||||
throw const SmolError("Ed25519 public key must be 32 bytes");
|
||||
}
|
||||
final sign = bytes[31] >> 7;
|
||||
final y = leBytesToBigInt(bytes) & _mask255;
|
||||
if (y >= _p) {
|
||||
throw const SmolError("non-canonical Ed25519 public key");
|
||||
}
|
||||
final u = _mod(y * y - BigInt.one), v = _mod(_d * y * y + BigInt.one);
|
||||
final v2 = v * v % _p, v3 = v2 * v % _p, v4 = v2 * v2 % _p;
|
||||
var x = u * v3 % _p * _powMod(u * v4 % _p * v3 % _p, (_p - BigInt.from(5)) ~/ BigInt.from(8)) % _p;
|
||||
if (_mod(v * x % _p * x) != u) {
|
||||
if (_mod(v * x % _p * x) == _mod(-u)) {
|
||||
x = x * _powMod(BigInt.two, (_p - BigInt.one) ~/ BigInt.from(4)) % _p;
|
||||
} else {
|
||||
throw const SmolError("not a point on the Ed25519 curve");
|
||||
}
|
||||
}
|
||||
if (x == BigInt.zero && sign == 1) {
|
||||
throw const SmolError("invalid sign bit on x = 0");
|
||||
}
|
||||
if ((x & BigInt.one).toInt() != sign) x = _p - x;
|
||||
return _Point.fromAffine(x, y);
|
||||
}
|
||||
|
||||
BigInt _seedToScalar(Uint8List seed) {
|
||||
final h = sha512(seed);
|
||||
return leBytesToBigInt(clampScalar(h.sublist(0, 32)));
|
||||
}
|
||||
|
||||
Uint8List ed25519PublicKey(Uint8List seed) {
|
||||
if (seed.length != 32) {
|
||||
throw const SmolError("identity seed must be 32 bytes");
|
||||
}
|
||||
return _encodePoint(_scalarMult(_seedToScalar(seed), _Point.fromAffine(_b.x, _b.y)));
|
||||
}
|
||||
|
||||
Uint8List ed25519Sign(Uint8List seed, List<int> message) {
|
||||
final h = sha512(seed);
|
||||
final a = leBytesToBigInt(clampScalar(h.sublist(0, 32)));
|
||||
final publicKey =
|
||||
_encodePoint(_scalarMult(a, _Point.fromAffine(_b.x, _b.y)));
|
||||
final r = leBytesToBigInt(sha512(concat([h.sublist(32), message]))) % _l;
|
||||
final rEnc = _encodePoint(_scalarMult(r, _Point.fromAffine(_b.x, _b.y)));
|
||||
final k = leBytesToBigInt(sha512(concat([rEnc, publicKey, message]))) % _l;
|
||||
return concat([rEnc, bigIntToLeBytes((r + k * a) % _l, 32)]);
|
||||
}
|
||||
|
||||
bool ed25519Verify(Uint8List publicKey, List<int> message, Uint8List signature) {
|
||||
if (signature.length != 64) return false;
|
||||
try {
|
||||
final decodedPk = _decodePoint(publicKey);
|
||||
final decodedR = _decodePoint(signature.sublist(0, 32));
|
||||
final a = _Point.fromAffine(decodedPk.x, decodedPk.y);
|
||||
final r = _Point.fromAffine(decodedR.x, decodedR.y);
|
||||
final s = leBytesToBigInt(signature.sublist(32, 64));
|
||||
if (s >= _l) return false;
|
||||
final k = leBytesToBigInt(sha512(concat([signature.sublist(0, 32), publicKey, message]))) % _l;
|
||||
final lhs = _scalarMult(s, _Point.fromAffine(_b.x, _b.y));
|
||||
final rhs = _pointAdd(_scalarMult(k, a), r);
|
||||
return lhs.x * rhs.z % _p == rhs.x * lhs.z % _p &&
|
||||
lhs.y * rhs.z % _p == rhs.y * lhs.z % _p;
|
||||
} on Exception {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- §2 conversions between the identity key and X25519 -------------------------
|
||||
|
||||
Uint8List ed25519ToX25519(Uint8List publicKey) {
|
||||
final y = leBytesToBigInt(publicKey) & _mask255;
|
||||
if (y >= _p) {
|
||||
throw const SmolError("non-canonical Ed25519 public key");
|
||||
}
|
||||
if (_mod(BigInt.one - y) == BigInt.zero) {
|
||||
throw const SmolError("identity element has no X25519 image");
|
||||
}
|
||||
return bigIntToLeBytes(
|
||||
_mod(BigInt.one + y) * _powMod(BigInt.one - y, _p - BigInt.two) % _p, 32);
|
||||
}
|
||||
|
||||
Uint8List ed25519SeedToX25519(Uint8List seed) =>
|
||||
clampScalar(sha512(seed).sublist(0, 32));
|
||||
|
||||
10
lib/smol/errors.dart
Normal file
10
lib/smol/errors.dart
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/// Raised for protocol-level failures (malformed envelopes, bad statuses,
|
||||
/// unpinned servers); programmer errors keep throwing normally.
|
||||
class SmolError implements Exception {
|
||||
final String message;
|
||||
|
||||
const SmolError(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
118
lib/smol/noise.dart
Normal file
118
lib/smol/noise.dart
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// Noise_NX_25519_ChaChaPoly_SHA256 initiator (SPEC.md §4), rev-34 semantics.
|
||||
// The initiator is anonymous; the responder's static key arrives encrypted in
|
||||
// message two, which is what server pinning checks.
|
||||
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:smol_mail/smol/crypto.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
|
||||
const String _protocol = "Noise_NX_25519_ChaChaPoly_SHA256"; // exactly 32 bytes, so h = name
|
||||
|
||||
Uint8List _prologue() => utf8Bytes("smolmail/1");
|
||||
|
||||
// Noise's ChaChaPoly nonce: 4 zero bytes then the counter as u64 LE.
|
||||
Uint8List _nonce(int n) {
|
||||
final out = Uint8List(12);
|
||||
ByteData.view(out.buffer).setUint64(4, n, Endian.little);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// One direction of the post-handshake transport; tests substitute a
|
||||
/// passthrough so framing guards can be exercised without crypto.
|
||||
abstract class SessionCipher {
|
||||
Uint8List encrypt(Uint8List plaintext);
|
||||
|
||||
Uint8List decrypt(Uint8List sealed);
|
||||
}
|
||||
|
||||
// The key is unique per session, so the counter starting at zero is safe.
|
||||
class CipherState implements SessionCipher {
|
||||
final Uint8List key;
|
||||
int counter = 0;
|
||||
|
||||
CipherState(this.key);
|
||||
|
||||
@override
|
||||
Uint8List encrypt(Uint8List plaintext) {
|
||||
final sealed = aeadEncrypt(key, _nonce(counter), plaintext, Uint8List(0));
|
||||
counter++;
|
||||
return sealed;
|
||||
}
|
||||
|
||||
@override
|
||||
Uint8List decrypt(Uint8List sealed) {
|
||||
final plaintext = aeadDecrypt(key, _nonce(counter), sealed, Uint8List(0));
|
||||
counter++;
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
|
||||
class NxResult {
|
||||
final CipherState send, recv;
|
||||
final Uint8List serverStatic;
|
||||
final Uint8List handshakeHash;
|
||||
|
||||
const NxResult(this.send, this.recv, this.serverStatic, this.handshakeHash);
|
||||
}
|
||||
|
||||
class NxInitiator {
|
||||
late Uint8List h;
|
||||
late Uint8List ck;
|
||||
Uint8List? key;
|
||||
late Uint8List esk;
|
||||
late Uint8List epk;
|
||||
|
||||
NxInitiator() {
|
||||
h = utf8Bytes(_protocol);
|
||||
ck = Uint8List.fromList(h);
|
||||
mixHash(_prologue());
|
||||
}
|
||||
|
||||
void mixHash(Uint8List data) {
|
||||
h = sha256(concat([h, data]));
|
||||
}
|
||||
|
||||
void mixKey(Uint8List ikm) {
|
||||
final okm = hkdfSha256(ikm, ck, Uint8List(0), 64);
|
||||
ck = okm.sublist(0, 32);
|
||||
key = okm.sublist(32);
|
||||
}
|
||||
|
||||
// Message one is just our ephemeral public key. No key is set yet, so the
|
||||
// empty payload travels in the clear — and is still mixed into h.
|
||||
Uint8List writeMessage1([Uint8List? esk]) {
|
||||
this.esk = esk ?? randomBytes(32);
|
||||
epk = x25519Base(this.esk);
|
||||
mixHash(epk);
|
||||
mixHash(Uint8List(0));
|
||||
return Uint8List.fromList(epk);
|
||||
}
|
||||
|
||||
// Message two: e (plaintext), ee, then the responder's static and the
|
||||
// (empty) payload as AEAD ciphertexts chained through h. Each MixKey
|
||||
// restarts the nonce at zero.
|
||||
NxResult readMessage2(Uint8List message) {
|
||||
if (message.length != 32 + 48 + 16) {
|
||||
throw SmolError("unexpected NX message length ${message.length}");
|
||||
}
|
||||
final re = message.sublist(0, 32);
|
||||
mixHash(re);
|
||||
mixKey(x25519(esk, re));
|
||||
final serverStatic = decryptAndHash(message.sublist(32, 80));
|
||||
mixKey(x25519(esk, serverStatic)); // es
|
||||
final payload = decryptAndHash(message.sublist(80));
|
||||
if (payload.isNotEmpty) throw const SmolError("unexpected payload in handshake");
|
||||
final handshakeHash = h;
|
||||
// Split(): two transport keys from the final chaining key, zero-length ikm
|
||||
final okm = hkdfSha256(Uint8List(0), ck, Uint8List(0), 64);
|
||||
return NxResult(CipherState(okm.sublist(0, 32)), CipherState(okm.sublist(32)),
|
||||
serverStatic, handshakeHash);
|
||||
}
|
||||
|
||||
Uint8List decryptAndHash(Uint8List sealed) {
|
||||
final plaintext = aeadDecrypt(key!, _nonce(0), sealed, h);
|
||||
mixHash(sealed);
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
580
lib/smol/proto.dart
Normal file
580
lib/smol/proto.dart
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
// Smol Mail protocol, version 1 (../smolmail SPEC.md): addresses, sealed and
|
||||
// signed envelopes, body frontmatter, key rotation, and the framed request
|
||||
// and response bodies of the five operations.
|
||||
|
||||
import "dart:math";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:smol_mail/smol/crypto.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/noise.dart";
|
||||
|
||||
const defaultPort = 1961;
|
||||
const keyLen = 32, sigLen = 64, certLen = 136, idLen = 16;
|
||||
const maxFrame = 1 << 20, noisePayload = 65535 - 16, padTo = 1024;
|
||||
const envelopeHeader = 69, payloadHeader = 45, maxChain = 16;
|
||||
const _frontmatterMax = 4096, _frontmatterKeys = 64;
|
||||
|
||||
const opAuth = 0x00, opResolve = 0x01, opSend = 0x02, opFetch = 0x03,
|
||||
opDelete = 0x04, opRegister = 0x05;
|
||||
|
||||
const _statusNames = {
|
||||
0: "ok", 1: "malformed", 2: "bad version", 3: "unknown user",
|
||||
4: "auth required", 5: "auth failed", 6: "quota exceeded", 7: "too large",
|
||||
8: "rate limited", 9: "not permitted", 10: "internal error",
|
||||
};
|
||||
|
||||
String statusName(int status) => _statusNames[status] ?? "$status";
|
||||
|
||||
final _label = (
|
||||
auth: utf8Bytes("smolmail/1 auth"),
|
||||
seal: utf8Bytes("smolmail/1 seal"),
|
||||
msg: utf8Bytes("smolmail/1 msg"),
|
||||
id: utf8Bytes("smolmail/1 id"),
|
||||
rotate: utf8Bytes("smolmail/1 rotate"),
|
||||
);
|
||||
|
||||
// --- encoding helpers ---------------------------------------------------------
|
||||
|
||||
const _b32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
String b32encode(List<int> bytes) {
|
||||
var out = "";
|
||||
var value = 0, bits = 0;
|
||||
for (final b in bytes) {
|
||||
value = (value << 8) | b;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
bits -= 5;
|
||||
out += _b32[(value >>> bits) & 31];
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += _b32[(value << (5 - bits)) & 31];
|
||||
return out.toLowerCase();
|
||||
}
|
||||
|
||||
Uint8List b32decode(String text) {
|
||||
final out = <int>[];
|
||||
var value = 0, bits = 0;
|
||||
final clean = text.trim().toUpperCase().replaceAll(RegExp(r"=+$"), "");
|
||||
for (final ch in clean.split("")) {
|
||||
final idx = _b32.indexOf(ch);
|
||||
if (idx < 0) throw SmolError("invalid base32 character '$ch'");
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out.add((value >>> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
return Uint8List.fromList(out);
|
||||
}
|
||||
|
||||
// §3: the first 20 base32 characters of the identity, in groups of four.
|
||||
String fingerprint(Uint8List identity) {
|
||||
final s = b32encode(identity).substring(0, 20);
|
||||
return RegExp(".{4}").allMatches(s).map((m) => m[0]).join(" ");
|
||||
}
|
||||
|
||||
Uint8List u16be(int n) => Uint8List.fromList([(n >> 8) & 0xff, n & 0xff]);
|
||||
|
||||
Uint8List u32be(int n) => Uint8List.fromList(
|
||||
[(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
||||
|
||||
// Dart 3.2's ByteData has no setBigInt, so write big-endian manually.
|
||||
Uint8List i64be(BigInt n) {
|
||||
final out = Uint8List(8);
|
||||
for (var i = 0; i < 8; i++) {
|
||||
out[i] = ((n >> (8 * (7 - i))) & BigInt.from(0xff)).toInt();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
int nowSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
// Fail-closed reader; every parse raises rather than reading past the end.
|
||||
class Reader {
|
||||
final Uint8List buf;
|
||||
int pos = 0;
|
||||
|
||||
Reader(this.buf);
|
||||
|
||||
Uint8List take(int n) {
|
||||
if (n < 0 || pos + n > buf.length) throw const SmolError("truncated message");
|
||||
final out = buf.sublist(pos, pos + n);
|
||||
pos += n;
|
||||
return out;
|
||||
}
|
||||
|
||||
int u8() => take(1)[0];
|
||||
|
||||
int u16() {
|
||||
final b = take(2);
|
||||
return (b[0] << 8) | b[1];
|
||||
}
|
||||
|
||||
int u32() => ByteData.view(take(4).buffer).getUint32(0);
|
||||
|
||||
int i64() => ByteData.view(take(8).buffer).getInt64(0);
|
||||
|
||||
int get left => buf.length - pos;
|
||||
}
|
||||
|
||||
// --- identity -----------------------------------------------------------------
|
||||
|
||||
// §2: an Ed25519 keypair with the X25519 agreement keys derived from it.
|
||||
class SmolIdentity {
|
||||
final Uint8List seed;
|
||||
final Uint8List publicKey;
|
||||
|
||||
const SmolIdentity(this.seed, this.publicKey);
|
||||
}
|
||||
|
||||
SmolIdentity identityFromSeed(Uint8List seed) {
|
||||
if (seed.length != keyLen) {
|
||||
throw const SmolError("identity seed must be $keyLen bytes");
|
||||
}
|
||||
return SmolIdentity(seed, ed25519PublicKey(seed));
|
||||
}
|
||||
|
||||
SmolIdentity newIdentity() => identityFromSeed(randomBytes(keyLen));
|
||||
|
||||
// --- addressing (§3) -----------------------------------------------------------
|
||||
|
||||
final _address =
|
||||
RegExp(r"^(?<user>[a-z0-9._-]{1,63})@(?<host>[^/:]+)(?::(?<port>\d+))?$");
|
||||
|
||||
class SmolAddress {
|
||||
final String user;
|
||||
final String host;
|
||||
final int port;
|
||||
|
||||
/// The key carried by a `smol://` address; null for short addresses.
|
||||
final Uint8List? identity;
|
||||
|
||||
const SmolAddress(this.user, this.host, this.port, this.identity);
|
||||
|
||||
String get short =>
|
||||
"$user@$host${port == defaultPort ? "" : ":$port"}";
|
||||
|
||||
String uri(Uint8List key) =>
|
||||
"smol://$user@$host${port == defaultPort ? "" : ":$port"}/${b32encode(key)}";
|
||||
}
|
||||
|
||||
SmolAddress parseAddress(String text) {
|
||||
text = text.trim();
|
||||
Uint8List? identity;
|
||||
if (text.startsWith("smol://")) {
|
||||
final rest = text.substring("smol://".length);
|
||||
final slash = rest.lastIndexOf("/");
|
||||
if (slash < 0) throw SmolError("$text: smol:// address carries no key");
|
||||
identity = b32decode(rest.substring(slash + 1));
|
||||
if (identity.length != keyLen) {
|
||||
throw SmolError(
|
||||
"$text: key is ${identity.length} bytes, expected $keyLen");
|
||||
}
|
||||
text = rest.substring(0, slash);
|
||||
}
|
||||
final m = _address.firstMatch(text.toLowerCase());
|
||||
if (m == null) throw SmolError("'$text' is not a valid address");
|
||||
final user = m.namedGroup("user")!;
|
||||
final host = m.namedGroup("host")!;
|
||||
if ("._-".contains(user[0]) || "._-".contains(user[user.length - 1])) {
|
||||
throw SmolError("$user may not begin or end with a separator");
|
||||
}
|
||||
final portText = m.namedGroup("port");
|
||||
final port = portText != null ? int.parse(portText) : defaultPort;
|
||||
return SmolAddress(user, host, port, identity);
|
||||
}
|
||||
|
||||
// --- message format (§5) -------------------------------------------------------
|
||||
|
||||
Uint8List messageId(List<int> envelope) =>
|
||||
sha256(concat([_label.id, envelope])).sublist(0, idLen);
|
||||
|
||||
class OpenedMessage {
|
||||
final Uint8List sender;
|
||||
final int time;
|
||||
final Uint8List body;
|
||||
final Uint8List id;
|
||||
|
||||
const OpenedMessage(this.sender, this.time, this.body, this.id);
|
||||
}
|
||||
|
||||
/// Options for [seal]; [esk] and [pad] exist so tests can pin them, mirroring
|
||||
/// the spec's fixed-ephemeral vectors.
|
||||
class SealOptions {
|
||||
final Uint8List? esk;
|
||||
final bool pad;
|
||||
|
||||
const SealOptions({this.esk, this.pad = true});
|
||||
}
|
||||
|
||||
// §5.2 and §5.3. The ephemeral key is thrown away after sealing, so the sender
|
||||
// cannot decrypt what they sent.
|
||||
Uint8List seal(SmolIdentity identity, Uint8List recipient, Uint8List body,
|
||||
[int? when, SealOptions opts = const SealOptions()]) {
|
||||
final esk = opts.esk ?? randomBytes(keyLen);
|
||||
final epk = x25519Base(esk);
|
||||
final key = hkdfSha256(x25519(esk, ed25519ToX25519(recipient)),
|
||||
concat([epk, recipient]), _label.seal);
|
||||
final header = concat([
|
||||
Uint8List.fromList([1]),
|
||||
identity.publicKey,
|
||||
i64be(BigInt.from(when ?? nowSeconds())),
|
||||
u32be(body.length),
|
||||
]);
|
||||
var plaintext = concat([
|
||||
header,
|
||||
body,
|
||||
ed25519Sign(identity.seed, concat([_label.msg, recipient, epk, header, body])),
|
||||
]);
|
||||
if (opts.pad) {
|
||||
plaintext = concat(
|
||||
[plaintext, Uint8List((padTo - plaintext.length % padTo) % padTo)]);
|
||||
}
|
||||
final aad = concat([utf8Bytes("SMOL"), Uint8List.fromList([1]), recipient, epk]);
|
||||
return concat([aad, aeadEncrypt(key, Uint8List(12), plaintext, aad)]);
|
||||
}
|
||||
|
||||
// Inverse of seal(); throws unless the signature and the recipient both check
|
||||
// out. [identities] may include retired keys, per §7.
|
||||
OpenedMessage unseal(List<SmolIdentity> identities, Uint8List envelope) {
|
||||
if (envelope.length < envelopeHeader + 16) {
|
||||
throw const SmolError("envelope too short");
|
||||
}
|
||||
final magic = utf8Bytes("SMOL");
|
||||
for (var i = 0; i < 4; i++) {
|
||||
if (magic[i] != envelope[i]) {
|
||||
throw const SmolError("not a Smol Mail envelope");
|
||||
}
|
||||
}
|
||||
if (envelope[4] != 1) {
|
||||
throw SmolError("unsupported envelope version ${envelope[4]}");
|
||||
}
|
||||
final to = envelope.sublist(5, 37), epk = envelope.sublist(37, 69);
|
||||
final sealed = envelope.sublist(69);
|
||||
SmolIdentity? me;
|
||||
for (final i in identities) {
|
||||
if (timingSafeEqual(i.publicKey, to)) {
|
||||
me = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (me == null) {
|
||||
throw SmolError(
|
||||
"addressed to ${b32encode(to).substring(0, 16)}…, not one of our keys");
|
||||
}
|
||||
final key = hkdfSha256(
|
||||
x25519(ed25519SeedToX25519(me.seed), epk), concat([epk, to]), _label.seal);
|
||||
Uint8List plaintext;
|
||||
try {
|
||||
plaintext = aeadDecrypt(key, Uint8List(12), sealed, envelope.sublist(0, envelopeHeader));
|
||||
} on SmolError {
|
||||
throw const SmolError("decryption failed: wrong key or corrupt envelope");
|
||||
}
|
||||
final r = Reader(plaintext);
|
||||
if (r.u8() != 1) throw const SmolError("unsupported payload version");
|
||||
final sender = r.take(keyLen);
|
||||
final when = r.i64();
|
||||
final bodyLen = r.u32();
|
||||
if (bodyLen > r.left) {
|
||||
throw const SmolError("payload body length exceeds the payload");
|
||||
}
|
||||
final body = r.take(bodyLen);
|
||||
final signature = r.take(sigLen); // trailing bytes are padding
|
||||
if (!ed25519Verify(sender,
|
||||
concat([_label.msg, to, epk, plaintext.sublist(0, payloadHeader), body]),
|
||||
signature)) {
|
||||
throw const SmolError("signature does not verify");
|
||||
}
|
||||
return OpenedMessage(sender, when, body, messageId(envelope));
|
||||
}
|
||||
|
||||
// --- body frontmatter (§5.5) ---------------------------------------------------
|
||||
|
||||
final _fmKey = RegExp(r"^[A-Za-z0-9-]{1,64}$");
|
||||
|
||||
class Frontmatter {
|
||||
final Map<String, String> fields;
|
||||
final String body;
|
||||
|
||||
const Frontmatter(this.fields, this.body);
|
||||
}
|
||||
|
||||
// A flat `Key: value` block, deliberately not YAML. Any malformed line
|
||||
// invalidates the whole block, which is then returned as ordinary body text:
|
||||
// frontmatter fails closed toward display, never toward silent discard.
|
||||
Frontmatter parseFrontmatter(String text) {
|
||||
if (!text.startsWith("---\n")) return Frontmatter(const {}, text);
|
||||
final lines = text.split("\n");
|
||||
final close = lines.indexOf("---", 1);
|
||||
if (close < 0) return Frontmatter(const {}, text);
|
||||
final block = lines.sublist(1, close);
|
||||
final rest = lines.sublist(close + 1).join("\n");
|
||||
var encoded = 0;
|
||||
for (final line in block) {
|
||||
encoded += utf8Bytes(line).length + 1;
|
||||
}
|
||||
if (block.length > _frontmatterKeys || encoded > _frontmatterMax) {
|
||||
return Frontmatter(const {}, text);
|
||||
}
|
||||
final fields = <String, String>{};
|
||||
for (final line in block) {
|
||||
final colon = line.indexOf(":");
|
||||
final head = colon < 0 ? "" : line.substring(0, colon);
|
||||
if (colon < 0 || !_fmKey.hasMatch(head)) {
|
||||
return Frontmatter(const {}, text);
|
||||
}
|
||||
// first occurrence wins
|
||||
fields.putIfAbsent(head, () => line.substring(colon + 1).trim());
|
||||
}
|
||||
return Frontmatter(fields, rest);
|
||||
}
|
||||
|
||||
// Emit a block only when needed, including to escape a body that genuinely
|
||||
// begins with `---` (§5.5).
|
||||
String buildFrontmatter(Map<String, String> fields, String body) {
|
||||
final entries = fields.entries.where((e) => e.value.isNotEmpty).toList();
|
||||
if (entries.isEmpty && !body.startsWith("---\n")) return body;
|
||||
final block = entries.map((e) => "${e.key}: ${e.value}\n").join();
|
||||
return "---\n$block---\n$body";
|
||||
}
|
||||
|
||||
// --- key rotation (§7) ---------------------------------------------------------
|
||||
|
||||
Uint8List makeCert(SmolIdentity oldIdentity, Uint8List newSeed, [int? when]) {
|
||||
final newPub = ed25519PublicKey(newSeed);
|
||||
final time = i64be(BigInt.from(when ?? nowSeconds()));
|
||||
return concat([
|
||||
oldIdentity.publicKey,
|
||||
newPub,
|
||||
time,
|
||||
ed25519Sign(oldIdentity.seed,
|
||||
concat([_label.rotate, oldIdentity.publicKey, newPub, time])),
|
||||
]);
|
||||
}
|
||||
|
||||
// Accept a key change only when a signed chain leads from the key we hold to
|
||||
// the one the server now returns (§7).
|
||||
bool walkChain(Uint8List pinned, Uint8List current, List<Uint8List> chain) {
|
||||
if (timingSafeEqual(pinned, current)) return true;
|
||||
if (chain.isEmpty || chain.length > maxChain) return false;
|
||||
var key = pinned;
|
||||
var started = false;
|
||||
for (final cert in chain) {
|
||||
final old = cert.sublist(0, 32), next = cert.sublist(32, 64);
|
||||
final when = cert.sublist(64, 72), sig = cert.sublist(72);
|
||||
if (!started) {
|
||||
if (!timingSafeEqual(old, key)) continue; // a link predating the key we hold
|
||||
started = true;
|
||||
} else if (!timingSafeEqual(old, key)) {
|
||||
return false; // the chain is not continuous
|
||||
}
|
||||
if (!ed25519Verify(old, concat([_label.rotate, old, next, when]), sig)) {
|
||||
return false;
|
||||
}
|
||||
key = next;
|
||||
}
|
||||
return started && timingSafeEqual(key, current);
|
||||
}
|
||||
|
||||
// --- framing and operations (§4, §6) -------------------------------------------
|
||||
|
||||
/// An ordered byte pipe (TCP socket, or an in-memory queue in tests).
|
||||
abstract class Wire {
|
||||
void send(Uint8List bytes);
|
||||
|
||||
void close();
|
||||
|
||||
Future<Uint8List> readExact(int n);
|
||||
}
|
||||
|
||||
// One Noise session: application frames split across u16-prefixed Noise
|
||||
// messages, requests and responses as in §6.1.
|
||||
class Session {
|
||||
final Wire wire;
|
||||
final SessionCipher send, recv;
|
||||
|
||||
Session(this.wire, this.send, this.recv);
|
||||
|
||||
Future<Uint8List> _readNoise() async {
|
||||
final head = await wire.readExact(2);
|
||||
final length = (head[0] << 8) | head[1];
|
||||
if (length < 16) throw SmolError("server sent a $length-byte Noise message");
|
||||
return recv.decrypt(await wire.readExact(length));
|
||||
}
|
||||
|
||||
Future<Response> call(int op, [Uint8List? body]) async {
|
||||
final payload = body ?? Uint8List(0);
|
||||
final frame = concat([u32be(1 + payload.length), Uint8List.fromList([op]), payload]);
|
||||
if (frame.length > maxFrame + 4) {
|
||||
throw const SmolError("request exceeds the maximum frame size");
|
||||
}
|
||||
for (var off = 0; off < frame.length; off += noisePayload) {
|
||||
final packet = send.encrypt(frame.sublist(off, min(off + noisePayload, frame.length)));
|
||||
wire.send(concat([u16be(packet.length), packet]));
|
||||
}
|
||||
var length = -1;
|
||||
var have = <int>[];
|
||||
while (length < 0 || have.length < 4 + length) {
|
||||
have.addAll(await _readNoise());
|
||||
if (length < 0 && have.length >= 4) {
|
||||
length = (have[0] << 24) | (have[1] << 16) | (have[2] << 8) | have[3];
|
||||
// §6.1: the shortest response is a type byte and a status byte.
|
||||
if (length < 2 || length > maxFrame) {
|
||||
throw SmolError("server sent a frame of length $length");
|
||||
}
|
||||
}
|
||||
}
|
||||
final payloadOut = Uint8List.fromList(have.sublist(4, 4 + length));
|
||||
// §6.1: a response reuses the request's type byte. A mismatch means the
|
||||
// session desynchronised, which must not be mistaken for a status.
|
||||
if (payloadOut[0] != op) {
|
||||
throw SmolError(
|
||||
"server answered op 0x${payloadOut[0].toRadixString(16)}, expected 0x${op.toRadixString(16)}");
|
||||
}
|
||||
return Response(payloadOut[1], payloadOut.sublist(2));
|
||||
}
|
||||
}
|
||||
|
||||
class Response {
|
||||
final int status;
|
||||
final Uint8List body;
|
||||
|
||||
const Response(this.status, this.body);
|
||||
}
|
||||
|
||||
class OpenedSession {
|
||||
final Session session;
|
||||
final Uint8List serverStatic;
|
||||
final bool pinned;
|
||||
final Uint8List handshakeHash;
|
||||
|
||||
const OpenedSession(this.session, this.serverStatic, this.pinned, this.handshakeHash);
|
||||
}
|
||||
|
||||
// Handshake plus §4 pinning. Returns the session, the server's static key as
|
||||
// revealed by the handshake, and whether that key was already pinned.
|
||||
Future<OpenedSession> openSession(Wire wire, String host,
|
||||
{Uint8List? pinned}) async {
|
||||
final nx = NxInitiator();
|
||||
final m1 = nx.writeMessage1();
|
||||
wire.send(concat([u16be(m1.length), m1]));
|
||||
final head = await wire.readExact(2);
|
||||
final result = nx.readMessage2(await wire.readExact((head[0] << 8) | head[1]));
|
||||
if (pinned != null && !timingSafeEqual(pinned, result.serverStatic)) {
|
||||
throw SmolError("$host presented a different key than the one pinned\n"
|
||||
" pinned: ${b32encode(pinned)}\n"
|
||||
" presented: ${b32encode(result.serverStatic)}");
|
||||
}
|
||||
return OpenedSession(
|
||||
Session(wire, result.send, result.recv),
|
||||
result.serverStatic,
|
||||
pinned != null,
|
||||
result.handshakeHash);
|
||||
}
|
||||
|
||||
void expectOk(int status, String what) {
|
||||
if (status != 0) {
|
||||
throw SmolError("$what failed: ${statusName(status)} ($status)");
|
||||
}
|
||||
}
|
||||
|
||||
// §4 session authentication: sign the handshake hash, which binds the
|
||||
// signature to this session's server ephemeral and cannot be replayed.
|
||||
Future<void> authenticate(
|
||||
Session session, Uint8List handshakeHash, String username, SmolIdentity identity) async {
|
||||
final name = utf8Bytes(username);
|
||||
if (name.length > 255) throw const SmolError("username too long");
|
||||
final body = concat([
|
||||
Uint8List.fromList([name.length]),
|
||||
name,
|
||||
identity.publicKey,
|
||||
ed25519Sign(identity.seed, concat([_label.auth, handshakeHash])),
|
||||
]);
|
||||
expectOk((await session.call(opAuth, body)).status, "authentication");
|
||||
}
|
||||
|
||||
class Resolved {
|
||||
final Uint8List identity;
|
||||
final List<Uint8List> chain;
|
||||
|
||||
const Resolved(this.identity, this.chain);
|
||||
}
|
||||
|
||||
// RESOLVE, returning the current key and its rotation chain (§6.1).
|
||||
Future<Resolved> resolveOp(Session session, String user) async {
|
||||
final name = utf8Bytes(user);
|
||||
if (name.length > 255) throw const SmolError("username too long");
|
||||
final response =
|
||||
await session.call(opResolve, concat([Uint8List.fromList([name.length]), name]));
|
||||
expectOk(response.status, "resolving $user");
|
||||
final r = Reader(response.body);
|
||||
return Resolved(
|
||||
r.take(keyLen),
|
||||
List.generate(r.u8(), (_) => r.take(certLen)));
|
||||
}
|
||||
|
||||
Future<Uint8List> sendOp(Session session, Uint8List envelope) async {
|
||||
final response = await session.call(opSend, envelope);
|
||||
expectOk(response.status, "sending");
|
||||
return response.body.length == idLen
|
||||
? response.body
|
||||
: messageId(envelope);
|
||||
}
|
||||
|
||||
class FetchedRecord {
|
||||
final Uint8List id;
|
||||
final int receivedAt;
|
||||
final Uint8List envelope;
|
||||
|
||||
const FetchedRecord(this.id, this.receivedAt, this.envelope);
|
||||
}
|
||||
|
||||
Future<List<FetchedRecord>> fetchOp(Session session) async {
|
||||
final response = await session.call(opFetch);
|
||||
expectOk(response.status, "fetching");
|
||||
final r = Reader(response.body);
|
||||
return List.generate(r.u16(), (_) {
|
||||
final id = r.take(idLen);
|
||||
final receivedAt = r.i64();
|
||||
return FetchedRecord(id, receivedAt, r.take(r.u32()));
|
||||
});
|
||||
}
|
||||
|
||||
Future<int> deleteOp(Session session, List<Uint8List> ids) async {
|
||||
if (ids.length > 0xffff) throw const SmolError("too many ids for one DELETE");
|
||||
final body = concat([u16be(ids.length), ...ids]);
|
||||
final response = await session.call(opDelete, body);
|
||||
expectOk(response.status, "acknowledging");
|
||||
return Reader(response.body).u16();
|
||||
}
|
||||
|
||||
class RegisterOptions {
|
||||
final String token;
|
||||
final Uint8List? cert;
|
||||
|
||||
const RegisterOptions({this.token = "", this.cert});
|
||||
}
|
||||
|
||||
Future<void> registerOp(
|
||||
Session session, String username, SmolIdentity identity,
|
||||
[RegisterOptions opts = const RegisterOptions()]) async {
|
||||
final name = utf8Bytes(username);
|
||||
final tokenBytes = utf8Bytes(opts.token);
|
||||
final cert = opts.cert ?? Uint8List(0);
|
||||
if (name.length > 255 || tokenBytes.length > 255 || cert.length > 255) {
|
||||
throw const SmolError("REGISTER field too long");
|
||||
}
|
||||
final body = concat([
|
||||
Uint8List.fromList([name.length]),
|
||||
name,
|
||||
identity.publicKey,
|
||||
Uint8List.fromList([tokenBytes.length]),
|
||||
tokenBytes,
|
||||
Uint8List.fromList([cert.length]),
|
||||
cert,
|
||||
]);
|
||||
expectOk((await session.call(opRegister, body)).status, "registering $username");
|
||||
}
|
||||
463
lib/smol/store.dart
Normal file
463
lib/smol/store.dart
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
// Device state: identity, pins, contacts and read markers in one JSON blob;
|
||||
// sealed envelopes in a second Hive box, opened only on demand, so nothing at
|
||||
// rest is plaintext (the seed excepted — the device's app storage is the trust
|
||||
// boundary, like gsmol's browser profile).
|
||||
|
||||
import "dart:convert";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:hive_flutter/hive_flutter.dart";
|
||||
|
||||
import "package:smol_mail/smol/crypto.dart";
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/proto.dart";
|
||||
|
||||
const _stateBox = "smol";
|
||||
const _mailBox = "mail";
|
||||
const _stateKey = "state";
|
||||
|
||||
class StoredAccount {
|
||||
final String user;
|
||||
final String host;
|
||||
final int port;
|
||||
|
||||
const StoredAccount(this.user, this.host, this.port);
|
||||
}
|
||||
|
||||
/// A key this contact replaced, per §7/§8 — the only local record that a
|
||||
/// rotation happened, kept so the user can notice such changes.
|
||||
class ContactHistoryEntry {
|
||||
final Uint8List key;
|
||||
final int until; // epoch ms of the displacement
|
||||
|
||||
const ContactHistoryEntry(this.key, this.until);
|
||||
}
|
||||
|
||||
class StoredContact {
|
||||
final Uint8List key;
|
||||
final bool verified;
|
||||
final List<ContactHistoryEntry> history;
|
||||
|
||||
const StoredContact(this.key, this.verified, [this.history = const []]);
|
||||
}
|
||||
|
||||
class MailRecord {
|
||||
final String id; // hex of the 16-byte message id
|
||||
final Uint8List envelope;
|
||||
final int? receivedAt;
|
||||
final String? recipient; // sent copies only
|
||||
final int? sentAt;
|
||||
|
||||
const MailRecord(this.id, this.envelope,
|
||||
{this.receivedAt, this.recipient, this.sentAt});
|
||||
}
|
||||
|
||||
class ImportSummary {
|
||||
int pinsAdded = 0, pinsConflicted = 0, contactsAdded = 0,
|
||||
contactsConflicted = 0, mailAdded = 0, malformed = 0;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
"$mailAdded messages, $contactsAdded contacts ($contactsConflicted conflicted), "
|
||||
"$pinsAdded server keys ($pinsConflicted conflicted), $malformed malformed";
|
||||
}
|
||||
|
||||
class SmolStore {
|
||||
final Box _state;
|
||||
final Box _mail;
|
||||
|
||||
SmolStore(this._state, this._mail);
|
||||
|
||||
/// [stateBox]/[mailBox] exist so tests can hold several isolated stores
|
||||
/// in one process; production always uses the defaults.
|
||||
static Future<SmolStore> open(
|
||||
{String stateBox = _stateBox, String mailBox = _mailBox}) async {
|
||||
final state = await Hive.openBox(stateBox);
|
||||
final mail = await Hive.openBox(mailBox);
|
||||
return SmolStore(state, mail);
|
||||
}
|
||||
|
||||
Map _load() {
|
||||
final blob = _state.get(_stateKey);
|
||||
return blob is Map ? blob : <String, dynamic>{};
|
||||
}
|
||||
|
||||
void _update(Map Function(Map state) fn) {
|
||||
final next = fn(_load());
|
||||
_state.put(_stateKey, next);
|
||||
}
|
||||
|
||||
// --- identity --------------------------------------------------------------
|
||||
|
||||
Uint8List? seed() {
|
||||
final raw = _load()["seed"];
|
||||
return raw == null ? null : unhex(raw as String);
|
||||
}
|
||||
|
||||
/// The active identity, or null before the user creates or restores one.
|
||||
SmolIdentity? identity() {
|
||||
final s = seed();
|
||||
return s == null ? null : identityFromSeed(s);
|
||||
}
|
||||
|
||||
void setIdentity(Uint8List newSeed) {
|
||||
if (seed() != null) {
|
||||
throw const SmolIdentityExistsException();
|
||||
}
|
||||
_update((state) => state..["seed"] = hex(newSeed));
|
||||
}
|
||||
|
||||
// Rotation (§7): the old seed is retained, since mail sealed to a
|
||||
// superseded key is readable with nothing else.
|
||||
void rotateIdentity(Uint8List newSeed) {
|
||||
final old = seed();
|
||||
if (old == null) throw const SmolNoIdentityException();
|
||||
_update((state) {
|
||||
final retired = (state["retired"] as List? ?? [])
|
||||
..add({"seed": hex(old), "at": DateTime.now().millisecondsSinceEpoch});
|
||||
state["retired"] = retired;
|
||||
state["seed"] = hex(newSeed);
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
/// §7: seeds rotated away from are retained, since mail sealed to a
|
||||
/// superseded key is readable with nothing else.
|
||||
List<SmolIdentity> identities() {
|
||||
final s = seed();
|
||||
if (s == null) return const [];
|
||||
final retired = (_load()["retired"] as List? ?? const [])
|
||||
.whereType<Map>()
|
||||
.map((entry) => identityFromSeed(unhex(entry["seed"] as String)));
|
||||
return [identityFromSeed(s), ...retired];
|
||||
}
|
||||
|
||||
// --- account and server pins -------------------------------------------------
|
||||
|
||||
StoredAccount? account() {
|
||||
final a = _load()["account"];
|
||||
if (a is! Map) return null;
|
||||
return StoredAccount(
|
||||
a["user"] as String, a["host"] as String, a["port"] as int);
|
||||
}
|
||||
|
||||
void setAccount(SmolAddress address) {
|
||||
_update((state) => state
|
||||
..["account"] = {
|
||||
"user": address.user,
|
||||
"host": address.host,
|
||||
"port": address.port,
|
||||
});
|
||||
}
|
||||
|
||||
Uint8List? serverPin(String host) {
|
||||
final raw = ((_load()["servers"] as Map?) ?? {})[host];
|
||||
return raw == null ? null : b32decode(raw as String);
|
||||
}
|
||||
|
||||
void pinServer(String host, Uint8List key) {
|
||||
_update((state) {
|
||||
final servers = (state["servers"] as Map? ?? {}).cast<String, String>();
|
||||
servers[host] = b32encode(key);
|
||||
state["servers"] = servers;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
void unpinServer(String host) {
|
||||
_update((state) {
|
||||
(state["servers"] as Map?)?.remove(host);
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
List<(String, Uint8List)> allPins() {
|
||||
final servers = ((_load()["servers"] as Map?) ?? {}).cast<String, String>();
|
||||
return [for (final e in servers.entries) (e.key, b32decode(e.value))];
|
||||
}
|
||||
|
||||
// --- contacts ------------------------------------------------------------------
|
||||
|
||||
StoredContact? contact(String address) {
|
||||
final c = ((_load()["contacts"] as Map?) ?? {})[address];
|
||||
if (c is! Map) return null;
|
||||
final history = ((c["history"] as List?) ?? const [])
|
||||
.whereType<Map>()
|
||||
.map((e) => ContactHistoryEntry(
|
||||
b32decode(e["key"] as String), e["until"] as int))
|
||||
.toList();
|
||||
return StoredContact(b32decode(c["key"] as String),
|
||||
c["verified"] as bool, history);
|
||||
}
|
||||
|
||||
// A key that displaces another is kept in the history (§8): it is the
|
||||
// only local record that the contact rotated. Re-saving the same key is
|
||||
// not a rotation and must not add an entry.
|
||||
void saveContact(String address, Uint8List key, bool verified) {
|
||||
_update((state) {
|
||||
final contacts =
|
||||
(state["contacts"] as Map? ?? {}).cast<String, Map>();
|
||||
final wanted = b32encode(key);
|
||||
final previous = contacts[address];
|
||||
final history = ((previous?["history"] as List?) ?? const [])
|
||||
.whereType<Map>()
|
||||
.toList();
|
||||
if (previous != null && previous["key"] != wanted) {
|
||||
history.add({
|
||||
"key": previous["key"],
|
||||
"until": DateTime.now().millisecondsSinceEpoch,
|
||||
});
|
||||
}
|
||||
contacts[address] = {
|
||||
"key": wanted,
|
||||
"verified": verified,
|
||||
"seenAt": DateTime.now().millisecondsSinceEpoch,
|
||||
if (history.isNotEmpty) "history": history,
|
||||
};
|
||||
state["contacts"] = contacts;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
String? addressForKey(Uint8List key) {
|
||||
final contacts = ((_load()["contacts"] as Map?) ?? {}).cast<String, Map>();
|
||||
final wanted = b32encode(key);
|
||||
for (final entry in contacts.entries) {
|
||||
if (entry.value["key"] == wanted) return entry.key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<(String, StoredContact)> allContacts() {
|
||||
final contacts = ((_load()["contacts"] as Map?) ?? {}).cast<String, Map>();
|
||||
return [for (final entry in contacts.entries) (entry.key, contact(entry.key)!)];
|
||||
}
|
||||
|
||||
// --- read markers ---------------------------------------------------------------
|
||||
|
||||
void markRead(String idHex) {
|
||||
_update((state) {
|
||||
final read = (state["read"] as Map? ?? {}).cast<String, bool>();
|
||||
read[idHex] = true;
|
||||
state["read"] = read;
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
bool isRead(String idHex) =>
|
||||
((_load()["read"] as Map?) ?? {})[idHex] == true;
|
||||
|
||||
// --- sealed mail ------------------------------------------------------------
|
||||
|
||||
Map _recordToMap(MailRecord record) => {
|
||||
"id": record.id,
|
||||
"envelope": record.envelope,
|
||||
"receivedAt": record.receivedAt,
|
||||
"recipient": record.recipient,
|
||||
"sentAt": record.sentAt,
|
||||
};
|
||||
|
||||
MailRecord _mapToRecord(Map map) => MailRecord(
|
||||
map["id"] as String,
|
||||
(map["envelope"] as Uint8List),
|
||||
receivedAt: map["receivedAt"] as int?,
|
||||
recipient: map["recipient"] as String?,
|
||||
sentAt: map["sentAt"] as int?,
|
||||
);
|
||||
|
||||
static String mailKey(String folder, String id) => "$folder/$id";
|
||||
|
||||
Future<void> storeMessage(String folder, MailRecord record) =>
|
||||
_mail.put(mailKey(folder, record.id), _recordToMap(record));
|
||||
|
||||
/// Returns null when the id already exists, so fetch can leave server
|
||||
/// state alone.
|
||||
Future<MailRecord?> storeIfNew(String folder, MailRecord record) async {
|
||||
if (_mail.containsKey(mailKey(folder, record.id))) return null;
|
||||
await storeMessage(folder, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
List<MailRecord> listMessages(String folder) {
|
||||
final prefix = "$folder/";
|
||||
final rows = <MailRecord>[];
|
||||
for (final key in _mail.keys.cast<String>()) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
final row = _mail.get(key);
|
||||
if (row is Map) rows.add(_mapToRecord(row));
|
||||
}
|
||||
rows.sort((a, b) =>
|
||||
(b.receivedAt ?? b.sentAt ?? 0).compareTo(a.receivedAt ?? a.sentAt ?? 0));
|
||||
return rows;
|
||||
}
|
||||
|
||||
MailRecord? getMessage(String folder, String id) {
|
||||
final row = _mail.get(mailKey(folder, id));
|
||||
return row is Map ? _mapToRecord(row) : null;
|
||||
}
|
||||
|
||||
Future<void> deleteMessage(String folder, String id) =>
|
||||
_mail.delete(mailKey(folder, id));
|
||||
|
||||
// --- export / import: mail, contacts, pins — never the seed --------------------
|
||||
|
||||
/// The marker matches gsmol's web export, so backups move between the two
|
||||
/// clients. Deliberately excludes the seed: it has its own reveal-and-copy
|
||||
/// flow in settings, meant for a password manager, not a shareable file.
|
||||
Map<String, dynamic> exportData() {
|
||||
final state = _load();
|
||||
final contacts = ((state["contacts"] as Map?) ?? {}).cast<String, Map>();
|
||||
return {
|
||||
"gsmolExport": 1,
|
||||
"exportedAt": DateTime.now().millisecondsSinceEpoch,
|
||||
"servers": ((state["servers"] as Map?) ?? {}).cast<String, String>(),
|
||||
"contacts": {
|
||||
for (final entry in contacts.entries)
|
||||
entry.key: {
|
||||
"key": entry.value["key"],
|
||||
"verified": entry.value["verified"],
|
||||
if ((entry.value["history"] as List?)?.isNotEmpty == true)
|
||||
"history": entry.value["history"],
|
||||
}
|
||||
},
|
||||
"inbox": [
|
||||
for (final row in listMessages("inbox"))
|
||||
{
|
||||
"id": row.id,
|
||||
"receivedAt": row.receivedAt,
|
||||
"envelope": base64Encode(row.envelope),
|
||||
}
|
||||
],
|
||||
"sent": [
|
||||
for (final row in listMessages("sent"))
|
||||
{
|
||||
"id": row.id,
|
||||
"recipient": row.recipient,
|
||||
"sentAt": row.sentAt,
|
||||
"envelope": base64Encode(row.envelope),
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/// Never overwrites a trust binding that already differs locally — the same
|
||||
/// rule refreshContact()/saveReplyAddress() apply elsewhere. A malformed
|
||||
/// entry is skipped and counted, not fatal: one bad record cannot abort the
|
||||
/// rest of the import.
|
||||
Future<ImportSummary> importData(Map data) async {
|
||||
if (data["gsmolExport"] != 1) {
|
||||
throw const SmolError("not a SmolMail export file");
|
||||
}
|
||||
final summary = ImportSummary();
|
||||
|
||||
_update((state) {
|
||||
final servers = (state["servers"] as Map? ?? {}).cast<String, String>();
|
||||
final incomingPins = data["servers"] is Map ? data["servers"] as Map : null;
|
||||
if (data["servers"] != null && incomingPins == null) summary.malformed++;
|
||||
for (final entry in (incomingPins ?? const {}).entries) {
|
||||
final host = entry.key, key = entry.value;
|
||||
if (host is! String || key is! String || _pinKeyOk(key) != true) {
|
||||
summary.malformed++;
|
||||
continue;
|
||||
}
|
||||
if (!servers.containsKey(host)) {
|
||||
servers[host] = key;
|
||||
summary.pinsAdded++;
|
||||
} else if (servers[host] != key) {
|
||||
summary.pinsConflicted++;
|
||||
}
|
||||
}
|
||||
state["servers"] = servers;
|
||||
|
||||
final contacts = (state["contacts"] as Map? ?? {}).cast<String, Map>();
|
||||
final incoming = data["contacts"] is Map ? data["contacts"] as Map : null;
|
||||
if (data["contacts"] != null && incoming == null) summary.malformed++;
|
||||
for (final entry in (incoming ?? const {}).entries) {
|
||||
final address = entry.key, contact = entry.value;
|
||||
if (address is! String ||
|
||||
contact is! Map ||
|
||||
contact["key"] is! String ||
|
||||
_pinKeyOk(contact["key"] as String) != true) {
|
||||
summary.malformed++;
|
||||
continue;
|
||||
}
|
||||
if (!contacts.containsKey(address)) {
|
||||
contacts[address] = {
|
||||
"key": contact["key"],
|
||||
"verified": contact["verified"] == true,
|
||||
"seenAt": DateTime.now().millisecondsSinceEpoch,
|
||||
if (contact["history"] is List && (contact["history"] as List).isNotEmpty)
|
||||
"history": contact["history"],
|
||||
};
|
||||
summary.contactsAdded++;
|
||||
} else if (contacts[address]!["key"] != contact["key"]) {
|
||||
summary.contactsConflicted++;
|
||||
}
|
||||
}
|
||||
state["contacts"] = contacts;
|
||||
return state;
|
||||
});
|
||||
|
||||
for (final folder in ["inbox", "sent"]) {
|
||||
final rows = data[folder] is List ? data[folder] as List : null;
|
||||
if (data[folder] != null && rows == null) summary.malformed++;
|
||||
for (final row in rows ?? const []) {
|
||||
try {
|
||||
final map = row as Map;
|
||||
final record = MailRecord(
|
||||
map["id"] as String,
|
||||
base64Decode(map["envelope"] as String),
|
||||
receivedAt: folder == "inbox" ? map["receivedAt"] as int? : null,
|
||||
recipient: folder == "sent" ? map["recipient"] as String? : null,
|
||||
sentAt: folder == "sent" ? map["sentAt"] as int? : null,
|
||||
);
|
||||
if (await storeIfNew(folder, record) != null) summary.mailAdded++;
|
||||
} on Exception {
|
||||
summary.malformed++;
|
||||
} on TypeError {
|
||||
summary.malformed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
// A pin key must decode to exactly 32 bytes of base32.
|
||||
bool _pinKeyOk(String key) {
|
||||
try {
|
||||
return b32decode(key).length == keyLen;
|
||||
} on SmolError {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove every secret and every stored envelope; the UI must confirm first.
|
||||
Future<void> wipe() async {
|
||||
await _state.delete(_stateKey);
|
||||
await _mail.clear();
|
||||
}
|
||||
|
||||
int unreadCount() {
|
||||
var count = 0;
|
||||
for (final row in listMessages("inbox")) {
|
||||
if (!isRead(row.id)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// The store throws these typed errors so the UI can tell "no identity yet"
|
||||
/// and "an identity already exists" apart without string matching.
|
||||
class SmolIdentityExistsException implements Exception {
|
||||
const SmolIdentityExistsException();
|
||||
|
||||
@override
|
||||
String toString() => "an identity already exists; rotate it instead";
|
||||
}
|
||||
|
||||
class SmolNoIdentityException implements Exception {
|
||||
const SmolNoIdentityException();
|
||||
|
||||
@override
|
||||
String toString() => "no identity to rotate";
|
||||
}
|
||||
115
lib/smol/transport.dart
Normal file
115
lib/smol/transport.dart
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// The byte pipe to a smolmaild server: raw TCP (dart:io), framed elsewhere.
|
||||
// Mobile is this app's only target platform, so dart:io is fine here.
|
||||
|
||||
import "dart:async";
|
||||
import "dart:io";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:smol_mail/smol/errors.dart";
|
||||
import "package:smol_mail/smol/proto.dart";
|
||||
|
||||
/// Buffers the socket's stream and serves exact-length reads, so protocol
|
||||
/// code never sees a partial frame.
|
||||
class TcpWire implements Wire {
|
||||
final Socket _socket;
|
||||
final _chunks = <Uint8List>[];
|
||||
final _waiters = <_ReadRequest>[];
|
||||
int _buffered = 0;
|
||||
Object? _closed;
|
||||
late final StreamSubscription<Uint8List> _subscription;
|
||||
|
||||
TcpWire(this._socket) {
|
||||
_subscription = _socket.listen(_onData,
|
||||
onError: (Object error) => _fail(error),
|
||||
onDone: () => _fail(const SmolError("server closed the connection")));
|
||||
}
|
||||
|
||||
static Future<TcpWire> connect(String host, int port) async {
|
||||
try {
|
||||
// Mobile networks routinely need longer than a LAN handshake; 30s keeps
|
||||
// flaky handovers from surfacing as user-facing timeouts.
|
||||
return TcpWire(await Socket.connect(host, port,
|
||||
timeout: const Duration(seconds: 30)));
|
||||
} on SocketException catch (error) {
|
||||
throw SmolError("cannot reach $host:$port (${error.message})");
|
||||
}
|
||||
}
|
||||
|
||||
void _onData(Uint8List data) {
|
||||
_chunks.add(data);
|
||||
_buffered += data.length;
|
||||
_wake();
|
||||
}
|
||||
|
||||
void _wake() {
|
||||
_waiters.removeWhere((w) {
|
||||
if (_closed != null) {
|
||||
w.completer.completeError(_closed!);
|
||||
return true;
|
||||
}
|
||||
if (_buffered >= w.need) {
|
||||
w.completer.complete();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
void _fail(Object error) {
|
||||
_closed = error;
|
||||
for (final w in _waiters) {
|
||||
w.completer.completeError(error);
|
||||
}
|
||||
_waiters.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
void send(Uint8List bytes) {
|
||||
if (_closed != null) throw _closed!;
|
||||
_socket.add(bytes);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_subscription.cancel();
|
||||
_socket.destroy();
|
||||
_fail(const SmolError("connection closed"));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List> readExact(int n) async {
|
||||
if (_closed != null) throw _closed!;
|
||||
if (_buffered < n) {
|
||||
final request = _ReadRequest(n);
|
||||
_waiters.add(request);
|
||||
try {
|
||||
await request.completer.future;
|
||||
} finally {
|
||||
_waiters.remove(request);
|
||||
}
|
||||
if (_closed != null) throw _closed!;
|
||||
}
|
||||
final out = Uint8List(n);
|
||||
var off = 0;
|
||||
while (off < n) {
|
||||
final chunk = _chunks.first;
|
||||
final take = chunk.length < n - off ? chunk.length : n - off;
|
||||
out.setRange(off, off + take, chunk);
|
||||
if (take == chunk.length) {
|
||||
_chunks.removeAt(0);
|
||||
} else {
|
||||
_chunks[0] = Uint8List.sublistView(chunk, take);
|
||||
}
|
||||
off += take;
|
||||
_buffered -= take;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
class _ReadRequest {
|
||||
final int need;
|
||||
final completer = Completer<void>();
|
||||
|
||||
_ReadRequest(this.need);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue