Add Supa account and search changes

This commit is contained in:
Fabian Freund
2026-05-22 18:10:22 +02:00
parent 3a19865b2e
commit 51289f1266
374 changed files with 54061 additions and 5013 deletions
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:supabase/supabase.dart';
part 'account_auth_state.g.dart';
enum AccountAuthStatus { signedOut, signingIn, signedIn, error }
@CopyWith()
class AccountAuthState with FastEquatable {
final AccountAuthStatus status;
final String? email;
final String? displayName;
final String? userId;
final String? lastError;
final String? syncKey;
// The SupabaseClient is intentionally excluded from equality. Two auth
// states for the same user but with distinct client instances are still
// semantically equal — including identityHashCode here would defeat
// Riverpod's caching by treating every reissued state as different.
// ignore: missing_field_in_equatable_props
final SupabaseClient? client;
AccountAuthState({
this.status = AccountAuthStatus.signedOut,
this.email,
this.displayName,
this.userId,
this.lastError,
this.syncKey,
this.client,
});
bool get isSignedIn => status == AccountAuthStatus.signedIn;
bool get isSignedOut => status == AccountAuthStatus.signedOut;
bool get isSigningIn => status == AccountAuthStatus.signingIn;
bool get hasError => status == AccountAuthStatus.error;
bool get hasSyncKey => syncKey != null;
@override
List<Object?> get hashParameters => [
status,
email,
displayName,
userId,
lastError,
syncKey,
];
}
@@ -0,0 +1,126 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'account_auth_state.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$AccountAuthStateCWProxy {
AccountAuthState status(AccountAuthStatus status);
AccountAuthState email(String? email);
AccountAuthState displayName(String? displayName);
AccountAuthState userId(String? userId);
AccountAuthState lastError(String? lastError);
AccountAuthState syncKey(String? syncKey);
AccountAuthState client(SupabaseClient? client);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `AccountAuthState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// AccountAuthState(...).copyWith(id: 12, name: "My name")
/// ```
AccountAuthState call({
AccountAuthStatus status,
String? email,
String? displayName,
String? userId,
String? lastError,
String? syncKey,
SupabaseClient? client,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfAccountAuthState.copyWith(...)` or call `instanceOfAccountAuthState.copyWith.fieldName(value)` for a single field.
class _$AccountAuthStateCWProxyImpl implements _$AccountAuthStateCWProxy {
const _$AccountAuthStateCWProxyImpl(this._value);
final AccountAuthState _value;
@override
AccountAuthState status(AccountAuthStatus status) => call(status: status);
@override
AccountAuthState email(String? email) => call(email: email);
@override
AccountAuthState displayName(String? displayName) =>
call(displayName: displayName);
@override
AccountAuthState userId(String? userId) => call(userId: userId);
@override
AccountAuthState lastError(String? lastError) => call(lastError: lastError);
@override
AccountAuthState syncKey(String? syncKey) => call(syncKey: syncKey);
@override
AccountAuthState client(SupabaseClient? client) => call(client: client);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `AccountAuthState(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// AccountAuthState(...).copyWith(id: 12, name: "My name")
/// ```
AccountAuthState call({
Object? status = const $CopyWithPlaceholder(),
Object? email = const $CopyWithPlaceholder(),
Object? displayName = const $CopyWithPlaceholder(),
Object? userId = const $CopyWithPlaceholder(),
Object? lastError = const $CopyWithPlaceholder(),
Object? syncKey = const $CopyWithPlaceholder(),
Object? client = const $CopyWithPlaceholder(),
}) {
return AccountAuthState(
status: status == const $CopyWithPlaceholder() || status == null
? _value.status
// ignore: cast_nullable_to_non_nullable
: status as AccountAuthStatus,
email: email == const $CopyWithPlaceholder()
? _value.email
// ignore: cast_nullable_to_non_nullable
: email as String?,
displayName: displayName == const $CopyWithPlaceholder()
? _value.displayName
// ignore: cast_nullable_to_non_nullable
: displayName as String?,
userId: userId == const $CopyWithPlaceholder()
? _value.userId
// ignore: cast_nullable_to_non_nullable
: userId as String?,
lastError: lastError == const $CopyWithPlaceholder()
? _value.lastError
// ignore: cast_nullable_to_non_nullable
: lastError as String?,
syncKey: syncKey == const $CopyWithPlaceholder()
? _value.syncKey
// ignore: cast_nullable_to_non_nullable
: syncKey as String?,
client: client == const $CopyWithPlaceholder()
? _value.client
// ignore: cast_nullable_to_non_nullable
: client as SupabaseClient?,
);
}
}
extension $AccountAuthStateCopyWith on AccountAuthState {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfAccountAuthState.copyWith(...)` or `instanceOfAccountAuthState.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$AccountAuthStateCWProxy get copyWith => _$AccountAuthStateCWProxyImpl(this);
}
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:weblibre/features/account/data/models/persisted_session.dart';
part 'account_persisted_data.g.dart';
@CopyWith()
@JsonSerializable()
class AccountPersistedData with FastEquatable {
final PersistedSession? session;
final String? userId;
final String? email;
final String? displayName;
final String? pendingCodeVerifier;
final String? syncKey;
AccountPersistedData({
this.session,
this.userId,
this.email,
this.displayName,
this.pendingCodeVerifier,
this.syncKey,
});
factory AccountPersistedData.fromJson(Map<String, dynamic> json) =>
_$AccountPersistedDataFromJson(json);
Map<String, dynamic> toJson() => _$AccountPersistedDataToJson(this);
@override
List<Object?> get hashParameters => [
session,
userId,
email,
displayName,
pendingCodeVerifier,
syncKey,
];
}
@@ -0,0 +1,147 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'account_persisted_data.dart';
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class _$AccountPersistedDataCWProxy {
AccountPersistedData session(PersistedSession? session);
AccountPersistedData userId(String? userId);
AccountPersistedData email(String? email);
AccountPersistedData displayName(String? displayName);
AccountPersistedData pendingCodeVerifier(String? pendingCodeVerifier);
AccountPersistedData syncKey(String? syncKey);
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `AccountPersistedData(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// AccountPersistedData(...).copyWith(id: 12, name: "My name")
/// ```
AccountPersistedData call({
PersistedSession? session,
String? userId,
String? email,
String? displayName,
String? pendingCodeVerifier,
String? syncKey,
});
}
/// Callable proxy for `copyWith` functionality.
/// Use as `instanceOfAccountPersistedData.copyWith(...)` or call `instanceOfAccountPersistedData.copyWith.fieldName(value)` for a single field.
class _$AccountPersistedDataCWProxyImpl
implements _$AccountPersistedDataCWProxy {
const _$AccountPersistedDataCWProxyImpl(this._value);
final AccountPersistedData _value;
@override
AccountPersistedData session(PersistedSession? session) =>
call(session: session);
@override
AccountPersistedData userId(String? userId) => call(userId: userId);
@override
AccountPersistedData email(String? email) => call(email: email);
@override
AccountPersistedData displayName(String? displayName) =>
call(displayName: displayName);
@override
AccountPersistedData pendingCodeVerifier(String? pendingCodeVerifier) =>
call(pendingCodeVerifier: pendingCodeVerifier);
@override
AccountPersistedData syncKey(String? syncKey) => call(syncKey: syncKey);
@override
/// Creates a new instance with the provided field values.
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `AccountPersistedData(...).copyWith.fieldName(value)`.
///
/// Example:
/// ```dart
/// AccountPersistedData(...).copyWith(id: 12, name: "My name")
/// ```
AccountPersistedData call({
Object? session = const $CopyWithPlaceholder(),
Object? userId = const $CopyWithPlaceholder(),
Object? email = const $CopyWithPlaceholder(),
Object? displayName = const $CopyWithPlaceholder(),
Object? pendingCodeVerifier = const $CopyWithPlaceholder(),
Object? syncKey = const $CopyWithPlaceholder(),
}) {
return AccountPersistedData(
session: session == const $CopyWithPlaceholder()
? _value.session
// ignore: cast_nullable_to_non_nullable
: session as PersistedSession?,
userId: userId == const $CopyWithPlaceholder()
? _value.userId
// ignore: cast_nullable_to_non_nullable
: userId as String?,
email: email == const $CopyWithPlaceholder()
? _value.email
// ignore: cast_nullable_to_non_nullable
: email as String?,
displayName: displayName == const $CopyWithPlaceholder()
? _value.displayName
// ignore: cast_nullable_to_non_nullable
: displayName as String?,
pendingCodeVerifier: pendingCodeVerifier == const $CopyWithPlaceholder()
? _value.pendingCodeVerifier
// ignore: cast_nullable_to_non_nullable
: pendingCodeVerifier as String?,
syncKey: syncKey == const $CopyWithPlaceholder()
? _value.syncKey
// ignore: cast_nullable_to_non_nullable
: syncKey as String?,
);
}
}
extension $AccountPersistedDataCopyWith on AccountPersistedData {
/// Returns a callable class used to build a new instance with modified fields.
/// Example: `instanceOfAccountPersistedData.copyWith(...)` or `instanceOfAccountPersistedData.copyWith.fieldName(...)`.
// ignore: library_private_types_in_public_api
_$AccountPersistedDataCWProxy get copyWith =>
_$AccountPersistedDataCWProxyImpl(this);
}
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AccountPersistedData _$AccountPersistedDataFromJson(
Map<String, dynamic> json,
) => AccountPersistedData(
session: json['session'] == null
? null
: PersistedSession.fromJson(json['session'] as Map<String, dynamic>),
userId: json['userId'] as String?,
email: json['email'] as String?,
displayName: json['displayName'] as String?,
pendingCodeVerifier: json['pendingCodeVerifier'] as String?,
syncKey: json['syncKey'] as String?,
);
Map<String, dynamic> _$AccountPersistedDataToJson(
AccountPersistedData instance,
) => <String, dynamic>{
'session': instance.session?.toJson(),
'userId': instance.userId,
'email': instance.email,
'displayName': instance.displayName,
'pendingCodeVerifier': instance.pendingCodeVerifier,
'syncKey': instance.syncKey,
};
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'persisted_session.g.dart';
@JsonSerializable()
class PersistedSession with FastEquatable {
@JsonKey(name: 'access_token')
final String accessToken;
@JsonKey(name: 'refresh_token')
final String refreshToken;
@JsonKey(name: 'token_type')
final String tokenType;
@JsonKey(name: 'expires_in')
final int expiresIn;
PersistedSession({
required this.accessToken,
required this.refreshToken,
required this.tokenType,
required this.expiresIn,
});
factory PersistedSession.fromJson(Map<String, dynamic> json) =>
_$PersistedSessionFromJson(json);
Map<String, dynamic> toJson() => _$PersistedSessionToJson(this);
@override
List<Object?> get hashParameters => [
accessToken,
refreshToken,
tokenType,
expiresIn,
];
}
@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'persisted_session.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PersistedSession _$PersistedSessionFromJson(Map<String, dynamic> json) =>
PersistedSession(
accessToken: json['access_token'] as String,
refreshToken: json['refresh_token'] as String,
tokenType: json['token_type'] as String,
expiresIn: (json['expires_in'] as num).toInt(),
);
Map<String, dynamic> _$PersistedSessionToJson(PersistedSession instance) =>
<String, dynamic>{
'access_token': instance.accessToken,
'refresh_token': instance.refreshToken,
'token_type': instance.tokenType,
'expires_in': instance.expiresIn,
};
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:json_annotation/json_annotation.dart';
import 'package:weblibre/features/user/data/models/engine_settings.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/data/models/tor_settings.dart';
part 'settings_sync_envelope.g.dart';
@JsonSerializable()
class SettingsSyncPayload {
final GeneralSettings? general;
final EngineSettings? engine;
final TorSettings? tor;
SettingsSyncPayload({this.general, this.engine, this.tor});
factory SettingsSyncPayload.fromJson(Map<String, dynamic> json) =>
_$SettingsSyncPayloadFromJson(json);
Map<String, dynamic> toJson() => _$SettingsSyncPayloadToJson(this);
}
@JsonSerializable()
class SettingsSyncEnvelope {
@JsonKey(name: 'schema_version')
final int schemaVersion;
@JsonKey(name: 'exported_at')
final String exportedAt;
final SettingsSyncPayload payload;
SettingsSyncEnvelope({
required this.schemaVersion,
required this.exportedAt,
required this.payload,
});
factory SettingsSyncEnvelope.fromJson(Map<String, dynamic> json) =>
_$SettingsSyncEnvelopeFromJson(json);
Map<String, dynamic> toJson() => _$SettingsSyncEnvelopeToJson(this);
}
@@ -0,0 +1,46 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'settings_sync_envelope.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SettingsSyncPayload _$SettingsSyncPayloadFromJson(Map<String, dynamic> json) =>
SettingsSyncPayload(
general: json['general'] == null
? null
: GeneralSettings.fromJson(json['general'] as Map<String, dynamic>),
engine: json['engine'] == null
? null
: EngineSettings.fromJson(json['engine'] as Map<String, dynamic>),
tor: json['tor'] == null
? null
: TorSettings.fromJson(json['tor'] as Map<String, dynamic>),
);
Map<String, dynamic> _$SettingsSyncPayloadToJson(
SettingsSyncPayload instance,
) => <String, dynamic>{
'general': instance.general?.toJson(),
'engine': instance.engine?.toJson(),
'tor': instance.tor?.toJson(),
};
SettingsSyncEnvelope _$SettingsSyncEnvelopeFromJson(
Map<String, dynamic> json,
) => SettingsSyncEnvelope(
schemaVersion: (json['schema_version'] as num).toInt(),
exportedAt: json['exported_at'] as String,
payload: SettingsSyncPayload.fromJson(
json['payload'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$SettingsSyncEnvelopeToJson(
SettingsSyncEnvelope instance,
) => <String, dynamic>{
'schema_version': instance.schemaVersion,
'exported_at': instance.exportedAt,
'payload': instance.payload.toJson(),
};
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:fast_equatable/fast_equatable.dart';
class SubscriptionStatus with FastEquatable {
final bool hasActiveSubscription;
final DateTime? entitledUntil;
final String status;
final String? subscriptionStatus;
final bool cancelAtPeriodEnd;
final DateTime? currentPeriodEnd;
final String? planLabel;
SubscriptionStatus({
required this.hasActiveSubscription,
this.entitledUntil,
required this.status,
this.subscriptionStatus,
required this.cancelAtPeriodEnd,
this.currentPeriodEnd,
this.planLabel,
});
factory SubscriptionStatus.fromJson(Map<String, dynamic> json) {
return SubscriptionStatus(
hasActiveSubscription: json['has_active_subscription'] as bool? ?? false,
entitledUntil: json['entitled_until'] != null
? DateTime.parse(json['entitled_until'] as String)
: null,
status: json['status'] as String? ?? 'inactive',
subscriptionStatus: json['subscription_status'] as String?,
cancelAtPeriodEnd: json['cancel_at_period_end'] as bool? ?? false,
currentPeriodEnd: json['current_period_end'] != null
? DateTime.parse(json['current_period_end'] as String)
: null,
planLabel: json['plan_label'] as String?,
);
}
bool get isActive => hasActiveSubscription;
bool get isPaused => subscriptionStatus == 'paused';
bool get isPastDue => subscriptionStatus == 'past_due';
/// Subscription is on its way out: either explicitly scheduled to end at
/// period end, already in `canceled` (grace period), or
/// `scheduled_cancel`. The user can still resume from the portal until
/// the period ends.
bool get isWindingDown =>
cancelAtPeriodEnd ||
subscriptionStatus == 'canceled' ||
subscriptionStatus == 'scheduled_cancel';
/// Whether the backend reports any underlying subscription state at all.
/// When false the user has never had a sub (or it was wiped) and the UI
/// should offer "Subscribe" rather than a state-specific message.
bool get hasSubscriptionRecord => subscriptionStatus != null;
// Not `const` because `FastEquatable` carries a cached-hash field that
// disallows const construction. Value-equality from the mixin means
// `SubscriptionStatus.inactive == SubscriptionStatus(...same fields...)`
// still holds, which is what Riverpod's `select` dedupe cares about.
static final inactive = SubscriptionStatus(
hasActiveSubscription: false,
status: 'inactive',
cancelAtPeriodEnd: false,
);
@override
List<Object?> get hashParameters => [
hasActiveSubscription,
entitledUntil,
status,
subscriptionStatus,
cancelAtPeriodEnd,
currentPeriodEnd,
planLabel,
];
}