Add Supa account and search changes
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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 'dart:convert';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/account/data/models/account_persisted_data.dart';
|
||||
|
||||
part 'account_secure_store.g.dart';
|
||||
|
||||
/// Encapsulates persistence of [AccountPersistedData] in
|
||||
/// `flutter_secure_storage`. Kept as a thin wrapper so the auth repository
|
||||
/// stays focused on state-machine logic and can be exercised with a fake
|
||||
/// store in tests.
|
||||
///
|
||||
/// Storage holds the Supabase refresh token, the end-to-end sync key, and
|
||||
/// the in-flight PKCE verifier — all sensitive. We rely on the default
|
||||
/// Android backend, which (as of flutter_secure_storage v10) uses a custom
|
||||
/// AES-GCM cipher with a Keystore-wrapped key per-app; the older
|
||||
/// `encryptedSharedPreferences` option was deprecated when Google
|
||||
/// deprecated the Jetpack Security library, with automatic migration on
|
||||
/// first read.
|
||||
class AccountSecureStore {
|
||||
static const _storageKey = 'account_auth_data';
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
AccountSecureStore({FlutterSecureStorage? storage})
|
||||
: _storage = storage ?? const FlutterSecureStorage();
|
||||
|
||||
Future<AccountPersistedData> read() async {
|
||||
final json = await _storage.read(key: _storageKey);
|
||||
if (json == null) return AccountPersistedData();
|
||||
return AccountPersistedData.fromJson(
|
||||
jsonDecode(json) as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> write(AccountPersistedData data) {
|
||||
return _storage.write(key: _storageKey, value: jsonEncode(data.toJson()));
|
||||
}
|
||||
|
||||
Future<void> clear() {
|
||||
return _storage.delete(key: _storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
AccountSecureStore accountSecureStore(Ref ref) => AccountSecureStore();
|
||||
@@ -0,0 +1,58 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'account_secure_store.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(accountSecureStore)
|
||||
final accountSecureStoreProvider = AccountSecureStoreProvider._();
|
||||
|
||||
final class AccountSecureStoreProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AccountSecureStore,
|
||||
AccountSecureStore,
|
||||
AccountSecureStore
|
||||
>
|
||||
with $Provider<AccountSecureStore> {
|
||||
AccountSecureStoreProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'accountSecureStoreProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$accountSecureStoreHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<AccountSecureStore> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
AccountSecureStore create(Ref ref) {
|
||||
return accountSecureStore(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AccountSecureStore value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AccountSecureStore>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$accountSecureStoreHash() =>
|
||||
r'12a2383a575fb8a07a6fc1e8ac4a477c66db7009';
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* 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:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:supabase/supabase.dart';
|
||||
import 'package:weblibre/features/account/domain/repositories/account_auth.dart';
|
||||
|
||||
part 'account_sync_repository.g.dart';
|
||||
|
||||
enum SyncDocumentKind {
|
||||
weblibreSettings('weblibre_settings', 'Settings'),
|
||||
geckoUserJs('gecko_user_js', 'Gecko Prefs'),
|
||||
|
||||
/// Small encrypted canary written on first-device sync setup so a second
|
||||
/// device can verify the candidate sync key before persisting it. Not
|
||||
/// surfaced in any settings UI — it has no corresponding
|
||||
/// [SyncDocumentService] wired in [AccountSettingsScreen].
|
||||
syncValidationProbe('sync_validation_probe', 'Sync Validation Probe');
|
||||
|
||||
final String value;
|
||||
final String displayName;
|
||||
|
||||
const SyncDocumentKind(this.value, this.displayName);
|
||||
}
|
||||
|
||||
class SyncDocumentMetadata {
|
||||
final String id;
|
||||
final String? label;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final String? sourceDeviceId;
|
||||
final String? sourceAppVersion;
|
||||
final int schemaVersion;
|
||||
|
||||
SyncDocumentMetadata({
|
||||
required this.id,
|
||||
this.label,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.schemaVersion,
|
||||
this.sourceDeviceId,
|
||||
this.sourceAppVersion,
|
||||
});
|
||||
|
||||
factory SyncDocumentMetadata.fromRow(Map<String, dynamic> row) {
|
||||
return SyncDocumentMetadata(
|
||||
id: row['id'] as String,
|
||||
label: row['label'] as String?,
|
||||
createdAt: DateTime.parse(row['created_at'] as String),
|
||||
updatedAt: DateTime.parse(row['updated_at'] as String),
|
||||
schemaVersion: row['schema_version'] as int,
|
||||
sourceDeviceId: row['source_device_id'] as String?,
|
||||
sourceAppVersion: row['source_app_version'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SyncDocumentResult {
|
||||
final String contentBlob;
|
||||
final SyncDocumentMetadata metadata;
|
||||
|
||||
SyncDocumentResult({required this.contentBlob, required this.metadata});
|
||||
}
|
||||
|
||||
/// All write methods on this repository require the user to be signed in.
|
||||
/// Callers MUST gate on `ref.watch(accountSyncRepositoryProvider) != null`
|
||||
/// (or the equivalent `AccountAuthState.isSignedIn` check) before invoking
|
||||
/// `storeDocument`, `listDocuments`, `fetchDocument`, `deleteDocument`, or
|
||||
/// `updateLabel`. Calling them on a signed-out instance throws
|
||||
/// [StateError]; the repository deliberately doesn't fail silently because
|
||||
/// silent no-ops would hide UI bugs (a "Store" button that does nothing).
|
||||
@Riverpod(keepAlive: true)
|
||||
class AccountSyncRepository extends _$AccountSyncRepository {
|
||||
@override
|
||||
SupabaseClient? build() {
|
||||
final authState = ref.watch(accountAuthRepositoryProvider).value;
|
||||
if (authState == null || !authState.isSignedIn) return null;
|
||||
return authState.client;
|
||||
}
|
||||
|
||||
SupabaseClient get _client {
|
||||
final client = state;
|
||||
if (client == null) {
|
||||
throw StateError(
|
||||
'AccountSyncRepository called while signed-out. '
|
||||
'Gate the call site on accountAuthRepositoryProvider.isSignedIn '
|
||||
'or accountSyncRepositoryProvider != null first.',
|
||||
);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/// Stores a new document. Always inserts a new row.
|
||||
/// Returns the generated document UUID.
|
||||
Future<String> storeDocument({
|
||||
required SyncDocumentKind kind,
|
||||
required int schemaVersion,
|
||||
required String contentBlob,
|
||||
String? label,
|
||||
String? sourceDeviceId,
|
||||
String? sourceAppVersion,
|
||||
}) async {
|
||||
final row = await _client
|
||||
.from('account_sync_documents')
|
||||
.insert({
|
||||
'user_id': _client.auth.currentUser!.id,
|
||||
'document_kind': kind.value,
|
||||
'label': label,
|
||||
'schema_version': schemaVersion,
|
||||
'content_blob': contentBlob,
|
||||
'source_device_id': sourceDeviceId,
|
||||
'source_app_version': sourceAppVersion,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
return row['id'] as String;
|
||||
}
|
||||
|
||||
/// Lists all documents of a given kind, ordered by most recent first.
|
||||
/// Returns metadata only (no content).
|
||||
Future<List<SyncDocumentMetadata>> listDocuments({
|
||||
required SyncDocumentKind kind,
|
||||
}) async {
|
||||
final rows = await _client
|
||||
.from('account_sync_documents')
|
||||
.select(
|
||||
'id, label, created_at, updated_at, schema_version, '
|
||||
'source_device_id, source_app_version',
|
||||
)
|
||||
.eq('document_kind', kind.value)
|
||||
.order('updated_at', ascending: false);
|
||||
|
||||
return rows.map(SyncDocumentMetadata.fromRow).toList();
|
||||
}
|
||||
|
||||
/// Fetches a single document by ID (with encrypted content blob).
|
||||
Future<SyncDocumentResult?> fetchDocument({required String id}) async {
|
||||
final row = await _client
|
||||
.from('account_sync_documents')
|
||||
.select()
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
|
||||
if (row == null) return null;
|
||||
|
||||
return SyncDocumentResult(
|
||||
contentBlob: row['content_blob'] as String,
|
||||
metadata: SyncDocumentMetadata.fromRow(row),
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes a document by ID.
|
||||
Future<void> deleteDocument({required String id}) async {
|
||||
await _client.from('account_sync_documents').delete().eq('id', id);
|
||||
}
|
||||
|
||||
/// Updates the label of a document.
|
||||
Future<void> updateLabel({required String id, required String? label}) async {
|
||||
await _client
|
||||
.from('account_sync_documents')
|
||||
.update({'label': label})
|
||||
.eq('id', id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'account_sync_repository.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// All write methods on this repository require the user to be signed in.
|
||||
/// Callers MUST gate on `ref.watch(accountSyncRepositoryProvider) != null`
|
||||
/// (or the equivalent `AccountAuthState.isSignedIn` check) before invoking
|
||||
/// `storeDocument`, `listDocuments`, `fetchDocument`, `deleteDocument`, or
|
||||
/// `updateLabel`. Calling them on a signed-out instance throws
|
||||
/// [StateError]; the repository deliberately doesn't fail silently because
|
||||
/// silent no-ops would hide UI bugs (a "Store" button that does nothing).
|
||||
|
||||
@ProviderFor(AccountSyncRepository)
|
||||
final accountSyncRepositoryProvider = AccountSyncRepositoryProvider._();
|
||||
|
||||
/// All write methods on this repository require the user to be signed in.
|
||||
/// Callers MUST gate on `ref.watch(accountSyncRepositoryProvider) != null`
|
||||
/// (or the equivalent `AccountAuthState.isSignedIn` check) before invoking
|
||||
/// `storeDocument`, `listDocuments`, `fetchDocument`, `deleteDocument`, or
|
||||
/// `updateLabel`. Calling them on a signed-out instance throws
|
||||
/// [StateError]; the repository deliberately doesn't fail silently because
|
||||
/// silent no-ops would hide UI bugs (a "Store" button that does nothing).
|
||||
final class AccountSyncRepositoryProvider
|
||||
extends $NotifierProvider<AccountSyncRepository, SupabaseClient?> {
|
||||
/// All write methods on this repository require the user to be signed in.
|
||||
/// Callers MUST gate on `ref.watch(accountSyncRepositoryProvider) != null`
|
||||
/// (or the equivalent `AccountAuthState.isSignedIn` check) before invoking
|
||||
/// `storeDocument`, `listDocuments`, `fetchDocument`, `deleteDocument`, or
|
||||
/// `updateLabel`. Calling them on a signed-out instance throws
|
||||
/// [StateError]; the repository deliberately doesn't fail silently because
|
||||
/// silent no-ops would hide UI bugs (a "Store" button that does nothing).
|
||||
AccountSyncRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'accountSyncRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$accountSyncRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
AccountSyncRepository create() => AccountSyncRepository();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(SupabaseClient? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<SupabaseClient?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$accountSyncRepositoryHash() =>
|
||||
r'ce558b699974edce2ca06b3990cf09c245d09126';
|
||||
|
||||
/// All write methods on this repository require the user to be signed in.
|
||||
/// Callers MUST gate on `ref.watch(accountSyncRepositoryProvider) != null`
|
||||
/// (or the equivalent `AccountAuthState.isSignedIn` check) before invoking
|
||||
/// `storeDocument`, `listDocuments`, `fetchDocument`, `deleteDocument`, or
|
||||
/// `updateLabel`. Calling them on a signed-out instance throws
|
||||
/// [StateError]; the repository deliberately doesn't fail silently because
|
||||
/// silent no-ops would hide UI bugs (a "Store" button that does nothing).
|
||||
|
||||
abstract class _$AccountSyncRepository extends $Notifier<SupabaseClient?> {
|
||||
SupabaseClient? build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<SupabaseClient?, SupabaseClient?>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<SupabaseClient?, SupabaseClient?>,
|
||||
SupabaseClient?,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
// Supabase project configuration for the WebLibre native app.
|
||||
//
|
||||
// All three values are overridable at build time via `--dart-define` so
|
||||
// developer / staging / production builds can point at distinct Supabase
|
||||
// projects without code changes. The defaults match the current dev
|
||||
// project; the anon key is safe to ship in client binaries by design
|
||||
// (it's a public JWT bound only to anon Postgres RLS).
|
||||
abstract final class SupabaseConfig {
|
||||
static const supabaseUrl = String.fromEnvironment(
|
||||
'SUPABASE_URL',
|
||||
defaultValue: 'https://wqpnmlqacxijdmbvcxun.supabase.co',
|
||||
);
|
||||
|
||||
static const supabaseAnonKey = String.fromEnvironment(
|
||||
'SUPABASE_ANON_KEY',
|
||||
defaultValue:
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndxcG5tbHFhY3hpamRtYnZjeHVuIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzU0NjQ1MjUsImV4cCI6MjA5MTA0MDUyNX0.5iifJWjp6bYZhfko-oGPPMuwMLAiFdfUXHQqk8W3wxA',
|
||||
);
|
||||
|
||||
static const accountWebUrl = String.fromEnvironment(
|
||||
'ACCOUNT_BACKEND_ORIGIN',
|
||||
defaultValue: 'https://account.weblibre.eu',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/*
|
||||
* 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 'dart:async';
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:supabase/supabase.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/core/providers/device_info.dart';
|
||||
import 'package:weblibre/features/about/domain/providers.dart';
|
||||
import 'package:weblibre/features/account/data/account_secure_store.dart';
|
||||
import 'package:weblibre/features/account/data/models/account_auth_state.dart';
|
||||
import 'package:weblibre/features/account/data/models/account_persisted_data.dart';
|
||||
import 'package:weblibre/features/account/data/models/persisted_session.dart';
|
||||
import 'package:weblibre/features/account/data/supabase_config.dart';
|
||||
import 'package:weblibre/features/account/domain/services/handoff_redeem_client.dart';
|
||||
import 'package:weblibre/features/account/domain/utils/pkce.dart';
|
||||
|
||||
// Re-export so call sites that already imported AccountAuthFlowException from
|
||||
// this repository keep compiling after the redeem client split.
|
||||
export 'package:weblibre/features/account/domain/services/handoff_redeem_client.dart'
|
||||
show AccountAuthFlowException;
|
||||
|
||||
part 'account_auth.g.dart';
|
||||
|
||||
/// Convert any thrown error into a message safe to show in the UI.
|
||||
/// Untrusted exception strings (e.g. `e.toString()` for arbitrary HTTP /
|
||||
/// platform errors) can include response bodies, headers, or auth tokens —
|
||||
/// log them in full but never put them in user-visible state.
|
||||
String _sanitizeAuthError(Object error, String fallback) {
|
||||
if (error is AccountAuthFlowException) {
|
||||
return error.userMessage;
|
||||
}
|
||||
if (error is AuthRetryableFetchException) {
|
||||
return 'Network error. Please check your connection and try again.';
|
||||
}
|
||||
if (error is AuthException) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class AccountAuthRepository extends _$AccountAuthRepository {
|
||||
StreamSubscription<AuthState>? _authSubscription;
|
||||
Timer? _signingInTimeout;
|
||||
Timer? _restoreRetryTimer;
|
||||
|
||||
AccountSecureStore get _store => ref.read(accountSecureStoreProvider);
|
||||
HandoffRedeemClient get _redeemClient =>
|
||||
ref.read(handoffRedeemClientProvider);
|
||||
|
||||
AccountAuthState get _currentOrEmpty => state.value ?? AccountAuthState();
|
||||
|
||||
@override
|
||||
Future<AccountAuthState> build() async {
|
||||
ref.onDispose(() async {
|
||||
_signingInTimeout?.cancel();
|
||||
_restoreRetryTimer?.cancel();
|
||||
await _authSubscription?.cancel();
|
||||
final client = state.value?.client;
|
||||
await client?.dispose();
|
||||
});
|
||||
|
||||
_restoreRetryTimer?.cancel();
|
||||
_restoreRetryTimer = null;
|
||||
|
||||
final data = await _store.read();
|
||||
|
||||
if (data.session == null) {
|
||||
return AccountAuthState();
|
||||
}
|
||||
|
||||
try {
|
||||
final client = _createClient();
|
||||
final response = await client.auth.setSession(data.session!.refreshToken);
|
||||
|
||||
if (response.session != null) {
|
||||
_listenToAuthState(client);
|
||||
final user = response.session!.user;
|
||||
|
||||
await _persistSession(response.session!, data);
|
||||
|
||||
return AccountAuthState(
|
||||
status: AccountAuthStatus.signedIn,
|
||||
email: user.email,
|
||||
displayName:
|
||||
user.userMetadata?['display_name'] as String? ??
|
||||
user.userMetadata?['full_name'] as String? ??
|
||||
user.email,
|
||||
userId: user.id,
|
||||
syncKey: data.syncKey,
|
||||
client: client,
|
||||
);
|
||||
} else {
|
||||
await client.dispose();
|
||||
return AccountAuthState();
|
||||
}
|
||||
} on AuthRetryableFetchException catch (e) {
|
||||
// Transient network error — preserve session and retry shortly.
|
||||
return _transientRestoreFailure(data, e);
|
||||
} on AuthException {
|
||||
// Definitive auth failure (expired/revoked token) — clear credentials.
|
||||
await _store.clear();
|
||||
return AccountAuthState();
|
||||
} catch (e) {
|
||||
// Non-auth error (e.g. SocketException) — also transient, preserve.
|
||||
return _transientRestoreFailure(data, e);
|
||||
}
|
||||
}
|
||||
|
||||
AccountAuthState _transientRestoreFailure(
|
||||
AccountPersistedData data,
|
||||
Object error,
|
||||
) {
|
||||
_scheduleRestoreRetry();
|
||||
return AccountAuthState(
|
||||
status: AccountAuthStatus.error,
|
||||
email: data.email,
|
||||
displayName: data.displayName ?? data.email,
|
||||
userId: data.userId,
|
||||
syncKey: data.syncKey,
|
||||
lastError: _sanitizeAuthError(
|
||||
error,
|
||||
'Could not restore your account session. Retrying shortly.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _scheduleRestoreRetry() {
|
||||
_restoreRetryTimer?.cancel();
|
||||
_restoreRetryTimer = Timer(const Duration(seconds: 30), () {
|
||||
if (ref.mounted) {
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -- Auth state listener ---------------------------------------------------
|
||||
|
||||
SupabaseClient _createClient() {
|
||||
return SupabaseClient(
|
||||
SupabaseConfig.supabaseUrl,
|
||||
SupabaseConfig.supabaseAnonKey,
|
||||
);
|
||||
}
|
||||
|
||||
void _listenToAuthState(SupabaseClient client) {
|
||||
unawaited(_authSubscription?.cancel());
|
||||
_authSubscription = client.auth.onAuthStateChange.listen((data) {
|
||||
if (data.event == AuthChangeEvent.signedOut ||
|
||||
// ignore: deprecated_member_use
|
||||
data.event == AuthChangeEvent.userDeleted) {
|
||||
unawaited(_handleSignedOut());
|
||||
} else if (data.event == AuthChangeEvent.tokenRefreshed &&
|
||||
data.session != null) {
|
||||
unawaited(_persistSessionRefresh(data.session!));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleSignedOut() async {
|
||||
await _store.clear();
|
||||
// Stashed Privacy Pass tokens survive sign-out: they are anonymous
|
||||
// blobs already redeemed against the user's credit balance, and the
|
||||
// backend cannot link them back to the issuing account. Clearing them
|
||||
// here would destroy prepaid value with no refund path.
|
||||
await _authSubscription?.cancel();
|
||||
final client = state.value?.client;
|
||||
await client?.dispose();
|
||||
state = AsyncData(AccountAuthState());
|
||||
}
|
||||
|
||||
// -- Sign-in flow ----------------------------------------------------------
|
||||
|
||||
Future<void> startSignIn() async {
|
||||
_signingInTimeout?.cancel();
|
||||
_signingInTimeout = null;
|
||||
state = AsyncData(
|
||||
_currentOrEmpty.copyWith(status: AccountAuthStatus.signingIn),
|
||||
);
|
||||
|
||||
try {
|
||||
final codes = PkceCodes.generate();
|
||||
|
||||
final data = await _store.read();
|
||||
await _store.write(data.copyWith(pendingCodeVerifier: codes.verifier));
|
||||
|
||||
final queryParams = <String, String>{
|
||||
'mode': 'handoff',
|
||||
'code_challenge': codes.challenge,
|
||||
};
|
||||
|
||||
final packageInfoData = ref.read(packageInfoProvider).value;
|
||||
if (packageInfoData != null) {
|
||||
queryParams['app_version'] =
|
||||
'${packageInfoData.version}+${packageInfoData.buildNumber}';
|
||||
}
|
||||
|
||||
final deviceInfoData = ref.read(androidDeviceInfoProvider).value;
|
||||
if (deviceInfoData != null) {
|
||||
queryParams['device_name'] = deviceInfoData.deviceName;
|
||||
}
|
||||
|
||||
final baseUri = Uri.parse(SupabaseConfig.accountWebUrl);
|
||||
final uri = baseUri.replace(queryParameters: queryParams);
|
||||
|
||||
final launched = await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||
if (!launched) {
|
||||
state = AsyncData(
|
||||
_currentOrEmpty.copyWith(
|
||||
status: AccountAuthStatus.error,
|
||||
lastError: 'Could not open sign-in page',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the timer last so any earlier error path doesn't have to think
|
||||
// about cancelling a timer it never started. Guard the body so that a
|
||||
// timer fired after handleHandoffCode has reset _signingInTimeout to
|
||||
// null does nothing.
|
||||
late final Timer timer;
|
||||
timer = Timer(const Duration(minutes: 5), () {
|
||||
if (!identical(_signingInTimeout, timer)) return;
|
||||
_signingInTimeout = null;
|
||||
if (state.value?.status == AccountAuthStatus.signingIn) {
|
||||
state = AsyncData(
|
||||
AccountAuthState(
|
||||
status: AccountAuthStatus.error,
|
||||
lastError: 'Sign-in timed out. Please try again.',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
_signingInTimeout = timer;
|
||||
} catch (e, s) {
|
||||
logger.e('startSignIn failed', error: e, stackTrace: s);
|
||||
state = AsyncData(
|
||||
_currentOrEmpty.copyWith(
|
||||
status: AccountAuthStatus.error,
|
||||
lastError: _sanitizeAuthError(
|
||||
e,
|
||||
'Could not open the sign-in page. Please try again.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelSignIn() async {
|
||||
_signingInTimeout?.cancel();
|
||||
_signingInTimeout = null;
|
||||
|
||||
// Clear the pending code verifier so a late browser callback is rejected.
|
||||
final data = await _store.read();
|
||||
// ignore: avoid_redundant_argument_values
|
||||
await _store.write(data.copyWith(pendingCodeVerifier: null));
|
||||
|
||||
state = AsyncData(AccountAuthState());
|
||||
}
|
||||
|
||||
Future<void> handleHandoffCode(String code) async {
|
||||
_signingInTimeout?.cancel();
|
||||
_signingInTimeout = null;
|
||||
state = AsyncData(
|
||||
_currentOrEmpty.copyWith(status: AccountAuthStatus.signingIn),
|
||||
);
|
||||
|
||||
try {
|
||||
final data = await _store.read();
|
||||
final codeVerifier = data.pendingCodeVerifier;
|
||||
|
||||
if (codeVerifier == null) {
|
||||
throw AccountAuthFlowException(
|
||||
'No pending sign-in found. Please start sign-in again.',
|
||||
);
|
||||
}
|
||||
|
||||
final result = await _redeemClient.redeem(
|
||||
handoffCode: code,
|
||||
codeVerifier: codeVerifier,
|
||||
);
|
||||
|
||||
final refreshToken = result.session['refresh_token'] as String;
|
||||
|
||||
final previousClient = _currentOrEmpty.client;
|
||||
final newClient = _createClient();
|
||||
|
||||
try {
|
||||
await newClient.auth.setSession(refreshToken);
|
||||
} catch (e) {
|
||||
await newClient.dispose();
|
||||
rethrow;
|
||||
}
|
||||
|
||||
await previousClient?.dispose();
|
||||
_listenToAuthState(newClient);
|
||||
|
||||
// Persist session and clear the verifier in one write. Reuse the
|
||||
// existing persisted record via copyWith so any unrelated fields
|
||||
// (notably syncKey) survive a re-sign-in without being clobbered.
|
||||
final persistedSession = PersistedSession.fromJson(result.session);
|
||||
final user = result.session['user'] as Map<String, dynamic>?;
|
||||
await _store.write(
|
||||
data.copyWith(
|
||||
session: persistedSession,
|
||||
userId: user?['id'] as String?,
|
||||
email: user?['email'] as String?,
|
||||
displayName:
|
||||
(user?['user_metadata'] as Map<String, dynamic>?)?['display_name']
|
||||
as String?,
|
||||
// ignore: avoid_redundant_argument_values
|
||||
pendingCodeVerifier: null,
|
||||
),
|
||||
);
|
||||
|
||||
state = AsyncData(
|
||||
AccountAuthState(
|
||||
status: AccountAuthStatus.signedIn,
|
||||
email: result.account['email'] as String?,
|
||||
displayName: result.account['display_name'] as String?,
|
||||
userId: result.account['user_id'] as String?,
|
||||
syncKey: _currentOrEmpty.syncKey,
|
||||
client: newClient,
|
||||
),
|
||||
);
|
||||
} catch (e, s) {
|
||||
logger.e('handleHandoffCode failed', error: e, stackTrace: s);
|
||||
state = AsyncData(
|
||||
_currentOrEmpty.copyWith(
|
||||
status: AccountAuthStatus.error,
|
||||
lastError: _sanitizeAuthError(e, 'Sign-in failed. Please try again.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
try {
|
||||
await state.value?.client?.auth.signOut();
|
||||
} catch (_) {
|
||||
// Sign out may fail if the session is already invalid
|
||||
}
|
||||
await _handleSignedOut();
|
||||
}
|
||||
|
||||
// -- Sync key management ---------------------------------------------------
|
||||
|
||||
Future<void> setSyncKey(String key) async {
|
||||
final data = await _store.read();
|
||||
await _store.write(data.copyWith(syncKey: key));
|
||||
state = AsyncData(_currentOrEmpty.copyWith(syncKey: key));
|
||||
}
|
||||
|
||||
Future<void> clearSyncKey() async {
|
||||
final data = await _store.read();
|
||||
// ignore: avoid_redundant_argument_values
|
||||
await _store.write(data.copyWith(syncKey: null));
|
||||
// ignore: avoid_redundant_argument_values
|
||||
state = AsyncData(_currentOrEmpty.copyWith(syncKey: null));
|
||||
}
|
||||
|
||||
// -- Session persistence helpers ------------------------------------------
|
||||
|
||||
Future<void> _persistSession(
|
||||
Session session,
|
||||
AccountPersistedData current,
|
||||
) async {
|
||||
await _store.write(
|
||||
current.copyWith(
|
||||
session: PersistedSession(
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken!,
|
||||
tokenType: session.tokenType,
|
||||
expiresIn: session.expiresIn ?? 3600,
|
||||
),
|
||||
userId: session.user.id,
|
||||
email: session.user.email,
|
||||
displayName:
|
||||
session.user.userMetadata?['display_name'] as String? ??
|
||||
session.user.userMetadata?['full_name'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _persistSessionRefresh(Session session) async {
|
||||
final data = await _store.read();
|
||||
await _persistSession(session, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'account_auth.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(AccountAuthRepository)
|
||||
final accountAuthRepositoryProvider = AccountAuthRepositoryProvider._();
|
||||
|
||||
final class AccountAuthRepositoryProvider
|
||||
extends $AsyncNotifierProvider<AccountAuthRepository, AccountAuthState> {
|
||||
AccountAuthRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'accountAuthRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$accountAuthRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
AccountAuthRepository create() => AccountAuthRepository();
|
||||
}
|
||||
|
||||
String _$accountAuthRepositoryHash() =>
|
||||
r'604bd3954347d0ed6cd4f894bff8388642787104';
|
||||
|
||||
abstract class _$AccountAuthRepository
|
||||
extends $AsyncNotifier<AccountAuthState> {
|
||||
FutureOr<AccountAuthState> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref =
|
||||
this.ref as $Ref<AsyncValue<AccountAuthState>, AccountAuthState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<AccountAuthState>, AccountAuthState>,
|
||||
AsyncValue<AccountAuthState>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/account/data/models/subscription_status.dart';
|
||||
import 'package:weblibre/features/account/domain/repositories/account_auth.dart';
|
||||
|
||||
part 'subscription_repository.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class SubscriptionRepository extends _$SubscriptionRepository {
|
||||
@override
|
||||
Future<SubscriptionStatus> build() async {
|
||||
final authState = ref.watch(accountAuthRepositoryProvider).value;
|
||||
if (authState == null || !authState.isSignedIn) {
|
||||
// Signed-out is not an error; it's a known "no subscription" state.
|
||||
return SubscriptionStatus.inactive;
|
||||
}
|
||||
|
||||
// Don't swallow RPC failures as `SubscriptionStatus.inactive` — that
|
||||
// renders identically to "user has no subscription" and would prompt
|
||||
// them to subscribe when the actual fix is to retry. Let errors
|
||||
// propagate so the AsyncValue carries them.
|
||||
final client = authState.client!;
|
||||
final response = await client.rpc('get_my_subscription_status').single();
|
||||
return SubscriptionStatus.fromJson(response);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
ref.invalidateSelf();
|
||||
// Fire-and-forget; consumers re-render from the new AsyncValue.
|
||||
try {
|
||||
await future;
|
||||
} catch (_) {
|
||||
// intentionally ignored — see comment above
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'subscription_repository.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(SubscriptionRepository)
|
||||
final subscriptionRepositoryProvider = SubscriptionRepositoryProvider._();
|
||||
|
||||
final class SubscriptionRepositoryProvider
|
||||
extends $AsyncNotifierProvider<SubscriptionRepository, SubscriptionStatus> {
|
||||
SubscriptionRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'subscriptionRepositoryProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$subscriptionRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SubscriptionRepository create() => SubscriptionRepository();
|
||||
}
|
||||
|
||||
String _$subscriptionRepositoryHash() =>
|
||||
r'c618435ad9e22316809a0a7f573dcea28301ba46';
|
||||
|
||||
abstract class _$SubscriptionRepository
|
||||
extends $AsyncNotifier<SubscriptionStatus> {
|
||||
FutureOr<SubscriptionStatus> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref =
|
||||
this.ref as $Ref<AsyncValue<SubscriptionStatus>, SubscriptionStatus>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<SubscriptionStatus>, SubscriptionStatus>,
|
||||
AsyncValue<SubscriptionStatus>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -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:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/account/domain/repositories/account_auth.dart';
|
||||
import 'package:weblibre/features/share_intent/domain/services/sharing_intent.dart';
|
||||
|
||||
part 'account_callback_handler.g.dart';
|
||||
|
||||
/// Parsed `weblibre://account/callback?code=...` deep link.
|
||||
class AccountCallback {
|
||||
final String handoffCode;
|
||||
|
||||
const AccountCallback({required this.handoffCode});
|
||||
}
|
||||
|
||||
/// Parses [data] as a WebLibre account callback URI. Returns `null` if it
|
||||
/// isn't one (any other intent) or if the `code` query parameter is
|
||||
/// missing/empty. Callers can check `!= null` instead of running two
|
||||
/// passes (used-to-be `isAccountCallbackUri` then `extractHandoffCode`).
|
||||
AccountCallback? tryParseAccountCallback(String data) {
|
||||
final uri = Uri.tryParse(data);
|
||||
if (uri == null) return null;
|
||||
if (uri.scheme != 'weblibre' ||
|
||||
uri.host != 'account' ||
|
||||
uri.path != '/callback') {
|
||||
return null;
|
||||
}
|
||||
final code = uri.queryParameters['code'];
|
||||
if (code == null || code.isEmpty) return null;
|
||||
return AccountCallback(handoffCode: code);
|
||||
}
|
||||
|
||||
/// Listens for account callback deep links and forwards handoff codes
|
||||
/// to the account auth repository.
|
||||
///
|
||||
/// This provider must be watched during app initialization to activate
|
||||
/// the callback listener.
|
||||
@Riverpod(keepAlive: true)
|
||||
void accountCallbackHandler(Ref ref) {
|
||||
final stream = ref.watch(accountCallbackStreamProvider);
|
||||
|
||||
final subscription = stream.listen((code) async {
|
||||
logger.i('Received account handoff callback');
|
||||
await ref
|
||||
.read(accountAuthRepositoryProvider.notifier)
|
||||
.handleHandoffCode(code);
|
||||
});
|
||||
|
||||
ref.onDispose(subscription.cancel);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'account_callback_handler.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Listens for account callback deep links and forwards handoff codes
|
||||
/// to the account auth repository.
|
||||
///
|
||||
/// This provider must be watched during app initialization to activate
|
||||
/// the callback listener.
|
||||
|
||||
@ProviderFor(accountCallbackHandler)
|
||||
final accountCallbackHandlerProvider = AccountCallbackHandlerProvider._();
|
||||
|
||||
/// Listens for account callback deep links and forwards handoff codes
|
||||
/// to the account auth repository.
|
||||
///
|
||||
/// This provider must be watched during app initialization to activate
|
||||
/// the callback listener.
|
||||
|
||||
final class AccountCallbackHandlerProvider
|
||||
extends $FunctionalProvider<void, void, void>
|
||||
with $Provider<void> {
|
||||
/// Listens for account callback deep links and forwards handoff codes
|
||||
/// to the account auth repository.
|
||||
///
|
||||
/// This provider must be watched during app initialization to activate
|
||||
/// the callback listener.
|
||||
AccountCallbackHandlerProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'accountCallbackHandlerProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$accountCallbackHandlerHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<void> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
void create(Ref ref) {
|
||||
return accountCallbackHandler(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$accountCallbackHandlerHash() =>
|
||||
r'8d8e627efed8c030a2a9fe179cc71bdc33dd79c0';
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* 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 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/account/data/supabase_config.dart';
|
||||
|
||||
part 'handoff_redeem_client.g.dart';
|
||||
|
||||
/// Thrown by [HandoffRedeemClient.redeem] when we want to surface a
|
||||
/// specific, already-safe message to the user (e.g. a server-supplied error
|
||||
/// string). Distinguishes "messages we trust to show verbatim" from
|
||||
/// arbitrary `Exception.toString()` output, which may carry HTTP bodies,
|
||||
/// tokens, or stack frames.
|
||||
class AccountAuthFlowException implements Exception {
|
||||
final String userMessage;
|
||||
AccountAuthFlowException(this.userMessage);
|
||||
|
||||
@override
|
||||
String toString() => userMessage;
|
||||
}
|
||||
|
||||
/// Successful response from the `handoff-redeem` Supabase function. Holds
|
||||
/// the raw `session` and `account` payloads so the caller decides how to
|
||||
/// persist them.
|
||||
class HandoffRedeemResult {
|
||||
final Map<String, dynamic> session;
|
||||
final Map<String, dynamic> account;
|
||||
|
||||
const HandoffRedeemResult({required this.session, required this.account});
|
||||
}
|
||||
|
||||
/// Stateless client for the account web app's `handoff-redeem` endpoint.
|
||||
/// Owns the HTTP transport and the response parsing so the auth repository
|
||||
/// only orchestrates state transitions around it.
|
||||
class HandoffRedeemClient {
|
||||
final http.Client _client;
|
||||
|
||||
HandoffRedeemClient({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
void close() => _client.close();
|
||||
|
||||
/// Exchange a one-time `handoff_code` plus the matching PKCE
|
||||
/// `code_verifier` for a Supabase session. Throws
|
||||
/// [AccountAuthFlowException] on a non-200 status with the server's error
|
||||
/// message when available, or a generic fallback otherwise.
|
||||
Future<HandoffRedeemResult> redeem({
|
||||
required String handoffCode,
|
||||
required String codeVerifier,
|
||||
}) async {
|
||||
const redeemUrl =
|
||||
'${SupabaseConfig.supabaseUrl}/functions/v1/handoff-redeem';
|
||||
final response = await _client.post(
|
||||
Uri.parse(redeemUrl),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'apikey': SupabaseConfig.supabaseAnonKey,
|
||||
},
|
||||
body: jsonEncode({
|
||||
'handoff_code': handoffCode,
|
||||
'code_verifier': codeVerifier,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
// Try to extract the server's error field. The full response body
|
||||
// is logged by the caller — we only surface the trusted message
|
||||
// field to the UI to avoid leaking response detail.
|
||||
String? serverMessage;
|
||||
try {
|
||||
final error = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
serverMessage = error['error'] as String?;
|
||||
} catch (_) {
|
||||
// Body wasn't JSON — fall back to a generic message so we don't
|
||||
// echo HTML/HTTP detail to the user.
|
||||
}
|
||||
throw AccountAuthFlowException(
|
||||
serverMessage ?? 'Sign-in failed. Please try again.',
|
||||
);
|
||||
}
|
||||
|
||||
final responseData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return HandoffRedeemResult(
|
||||
session: responseData['session'] as Map<String, dynamic>,
|
||||
account: responseData['account'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
HandoffRedeemClient handoffRedeemClient(Ref ref) {
|
||||
final client = HandoffRedeemClient();
|
||||
ref.onDispose(client.close);
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'handoff_redeem_client.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(handoffRedeemClient)
|
||||
final handoffRedeemClientProvider = HandoffRedeemClientProvider._();
|
||||
|
||||
final class HandoffRedeemClientProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
HandoffRedeemClient,
|
||||
HandoffRedeemClient,
|
||||
HandoffRedeemClient
|
||||
>
|
||||
with $Provider<HandoffRedeemClient> {
|
||||
HandoffRedeemClientProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'handoffRedeemClientProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$handoffRedeemClientHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<HandoffRedeemClient> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
HandoffRedeemClient create(Ref ref) {
|
||||
return handoffRedeemClient(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(HandoffRedeemClient value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<HandoffRedeemClient>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$handoffRedeemClientHash() =>
|
||||
r'6746d14547090c9ff4fcdc1c554fc7aa3d1e6795';
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/account/domain/utils/user_js_parser.dart';
|
||||
import 'package:weblibre/utils/filesystem.dart';
|
||||
|
||||
/// Reads persisted Gecko user prefs directly from the active profile's
|
||||
/// `prefs.js` file.
|
||||
///
|
||||
/// Gecko writes `user_pref(...)` entries only for prefs with a user-set value,
|
||||
/// so reading this file gives us the syncable modified-prefs view without
|
||||
/// needing a native bridge.
|
||||
class PrefsJsReader {
|
||||
final Directory selectedProfileDir;
|
||||
|
||||
PrefsJsReader({required this.selectedProfileDir});
|
||||
|
||||
/// Returns a map of user-set prefs parsed from `prefs.js`, or an empty map
|
||||
/// if no `prefs.js` file exists for the active profile.
|
||||
Future<Map<String, Object>> readUserPrefs() async {
|
||||
final file = _resolvePrefsJsFile();
|
||||
if (file == null || !await file.exists()) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final text = await file.readAsString();
|
||||
return parseUserJs(text).prefs;
|
||||
}
|
||||
|
||||
File? _resolvePrefsJsFile() {
|
||||
final profileIds = getMozillaProfileIds(selectedProfileDir);
|
||||
if (profileIds.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final candidates = profileIds
|
||||
.map(
|
||||
(id) => File(
|
||||
p.join(selectedProfileDir.path, 'files', 'mozilla', id, 'prefs.js'),
|
||||
),
|
||||
)
|
||||
.where((f) => f.existsSync())
|
||||
.toList();
|
||||
|
||||
if (candidates.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidates.length == 1) {
|
||||
return candidates.first;
|
||||
}
|
||||
|
||||
logger.w(
|
||||
'Multiple Gecko profiles with prefs.js found (${candidates.length}); '
|
||||
'selecting newest by modification time',
|
||||
);
|
||||
|
||||
candidates.sort(
|
||||
(a, b) => b.statSync().modified.compareTo(a.statSync().modified),
|
||||
);
|
||||
return candidates.first;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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 'dart:convert';
|
||||
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/filesystem.dart';
|
||||
import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart';
|
||||
import 'package:weblibre/features/account/domain/services/prefs_js_reader.dart';
|
||||
import 'package:weblibre/features/account/domain/services/sync_document_service.dart';
|
||||
import 'package:weblibre/features/account/domain/utils/user_js_parser.dart';
|
||||
import 'package:weblibre/features/account/domain/utils/user_js_serializer.dart';
|
||||
|
||||
part 'prefs_sync_service.g.dart';
|
||||
|
||||
const _schemaVersion = 1;
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class PrefsSyncService extends _$PrefsSyncService
|
||||
implements SyncDocumentService {
|
||||
final GeckoPrefService _prefService = GeckoPrefService();
|
||||
final PrefsJsReader _prefsReader = PrefsJsReader(
|
||||
selectedProfileDir: filesystem.selectedProfileDir,
|
||||
);
|
||||
|
||||
@override
|
||||
void build() {}
|
||||
|
||||
@override
|
||||
SyncDocumentKind get kind => SyncDocumentKind.geckoUserJs;
|
||||
|
||||
@override
|
||||
int get schemaVersion => _schemaVersion;
|
||||
|
||||
@override
|
||||
Future<List<int>> serializeCurrent() async {
|
||||
final userPrefs = await _prefsReader.readUserPrefs();
|
||||
|
||||
final text = serializeUserJs(
|
||||
userPrefs: userPrefs,
|
||||
schemaVersion: _schemaVersion,
|
||||
);
|
||||
|
||||
return utf8.encode(text);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> applyRestored(List<int> plaintext) async {
|
||||
final text = utf8.decode(plaintext);
|
||||
final parsed = parseUserJs(text);
|
||||
|
||||
if (parsed.schemaVersion != null &&
|
||||
parsed.schemaVersion! > _schemaVersion) {
|
||||
throw Exception(
|
||||
'Unsupported prefs schema version: ${parsed.schemaVersion} '
|
||||
'(this app supports up to $_schemaVersion)',
|
||||
);
|
||||
}
|
||||
|
||||
final remotePrefs = parsed.prefs;
|
||||
|
||||
// Find local user-set prefs whose exported form would have been preserved,
|
||||
// so we can reset those absent from the remote snapshot.
|
||||
final localPrefs = await _prefsReader.readUserPrefs();
|
||||
final syncableLocalKeys = localPrefs.entries
|
||||
.where((e) => isSyncablePrefValue(e.value))
|
||||
.map((e) => e.key)
|
||||
.toSet();
|
||||
|
||||
final prefsToReset = syncableLocalKeys
|
||||
.difference(remotePrefs.keys.toSet())
|
||||
.toList();
|
||||
|
||||
if (prefsToReset.isNotEmpty) {
|
||||
await _prefService.resetPrefs(prefsToReset);
|
||||
}
|
||||
|
||||
if (remotePrefs.isNotEmpty) {
|
||||
await _prefService.applyPrefs(remotePrefs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'prefs_sync_service.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(PrefsSyncService)
|
||||
final prefsSyncServiceProvider = PrefsSyncServiceProvider._();
|
||||
|
||||
final class PrefsSyncServiceProvider
|
||||
extends $NotifierProvider<PrefsSyncService, void> {
|
||||
PrefsSyncServiceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'prefsSyncServiceProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$prefsSyncServiceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
PrefsSyncService create() => PrefsSyncService();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$prefsSyncServiceHash() => r'9e95a066d751c12dc2a46bbe041c49e0389a7874';
|
||||
|
||||
abstract class _$PrefsSyncService extends $Notifier<void> {
|
||||
void build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<void, void>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<void, void>,
|
||||
void,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 'dart:convert';
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/account/data/models/settings_sync_envelope.dart';
|
||||
import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart';
|
||||
import 'package:weblibre/features/account/domain/services/sync_document_service.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/tor_settings.dart';
|
||||
|
||||
part 'settings_sync_service.g.dart';
|
||||
|
||||
const _schemaVersion = 1;
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class SettingsSyncService extends _$SettingsSyncService
|
||||
implements SyncDocumentService {
|
||||
@override
|
||||
void build() {}
|
||||
|
||||
@override
|
||||
SyncDocumentKind get kind => SyncDocumentKind.weblibreSettings;
|
||||
|
||||
@override
|
||||
int get schemaVersion => _schemaVersion;
|
||||
|
||||
@override
|
||||
Future<List<int>> serializeCurrent() async {
|
||||
final (general, engine, tor) = await (
|
||||
ref.read(generalSettingsRepositoryProvider.notifier).fetchSettings(),
|
||||
ref.read(engineSettingsRepositoryProvider.notifier).fetchSettings(),
|
||||
ref.read(torSettingsRepositoryProvider.notifier).fetchSettings(),
|
||||
).wait;
|
||||
|
||||
final envelope = SettingsSyncEnvelope(
|
||||
schemaVersion: _schemaVersion,
|
||||
exportedAt: DateTime.now().toUtc().toIso8601String(),
|
||||
payload: SettingsSyncPayload(general: general, engine: engine, tor: tor),
|
||||
);
|
||||
|
||||
return utf8.encode(jsonEncode(envelope.toJson()));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> applyRestored(List<int> plaintext) async {
|
||||
final json = jsonDecode(utf8.decode(plaintext)) as Map<String, dynamic>;
|
||||
final envelope = SettingsSyncEnvelope.fromJson(json);
|
||||
|
||||
if (envelope.schemaVersion > _schemaVersion) {
|
||||
throw Exception(
|
||||
'Unsupported settings schema version: ${envelope.schemaVersion} '
|
||||
'(this app supports up to $_schemaVersion)',
|
||||
);
|
||||
}
|
||||
|
||||
final payload = envelope.payload;
|
||||
|
||||
if (payload.general != null) {
|
||||
await ref
|
||||
.read(generalSettingsRepositoryProvider.notifier)
|
||||
.updateSettings((_) => payload.general!);
|
||||
}
|
||||
if (payload.engine != null) {
|
||||
await ref
|
||||
.read(engineSettingsRepositoryProvider.notifier)
|
||||
.updateSettings((_) => payload.engine!);
|
||||
}
|
||||
if (payload.tor != null) {
|
||||
await ref
|
||||
.read(torSettingsRepositoryProvider.notifier)
|
||||
.updateSettings((_) => payload.tor!);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'settings_sync_service.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(SettingsSyncService)
|
||||
final settingsSyncServiceProvider = SettingsSyncServiceProvider._();
|
||||
|
||||
final class SettingsSyncServiceProvider
|
||||
extends $NotifierProvider<SettingsSyncService, void> {
|
||||
SettingsSyncServiceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'settingsSyncServiceProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$settingsSyncServiceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SettingsSyncService create() => SettingsSyncService();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(void value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<void>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$settingsSyncServiceHash() =>
|
||||
r'3f7488d705394417b3d8ca6319c978d65f1ef35d';
|
||||
|
||||
abstract class _$SettingsSyncService extends $Notifier<void> {
|
||||
void build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<void, void>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<void, void>,
|
||||
void,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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:weblibre/features/account/data/repositories/account_sync_repository.dart';
|
||||
|
||||
/// Contract for document kinds that can be synced.
|
||||
///
|
||||
/// Implementations handle serialization and deserialization of their domain
|
||||
/// data. Encryption, repository interaction, and UI are handled externally
|
||||
/// by the reusable [SyncDocumentListSection] widget.
|
||||
abstract class SyncDocumentService {
|
||||
/// The document kind identifier for Supabase storage.
|
||||
SyncDocumentKind get kind;
|
||||
|
||||
/// Current schema version for this document kind.
|
||||
int get schemaVersion;
|
||||
|
||||
/// Serializes the current app state to plaintext bytes for encryption.
|
||||
Future<List<int>> serializeCurrent();
|
||||
|
||||
/// Deserializes decrypted plaintext bytes and applies them to app state.
|
||||
Future<void> applyRestored(List<int> plaintext);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// A PKCE code-verifier / code-challenge pair tied to a single sign-in
|
||||
/// attempt. The verifier stays on the device; the challenge is sent to the
|
||||
/// account web app and later redeemed alongside the verifier to prove the
|
||||
/// app instance that started the flow is the one finishing it.
|
||||
///
|
||||
/// **Encoding deviates from RFC 7636.** The reference spec mandates
|
||||
/// base64url for both verifier and `S256` challenge; this implementation
|
||||
/// uses lowercase hex on both sides:
|
||||
/// - verifier: 32 random bytes encoded as 64 hex characters (within the
|
||||
/// 43–128 range allowed by RFC 7636 §4.1).
|
||||
/// - challenge: hex digest of `SHA-256(UTF-8(verifier))`.
|
||||
///
|
||||
/// Both sides of the flow are owned (this client and the `handoff-redeem`
|
||||
/// Supabase function), so the wire format is internally consistent. The
|
||||
/// deviation matters only if the redeem endpoint is ever replaced with a
|
||||
/// standards-compliant OAuth server — in which case both `_generateCodeVerifier`
|
||||
/// and `_challengeFor` must switch to base64url to interoperate.
|
||||
class PkceCodes {
|
||||
final String verifier;
|
||||
final String challenge;
|
||||
|
||||
const PkceCodes({required this.verifier, required this.challenge});
|
||||
|
||||
factory PkceCodes.generate() {
|
||||
final verifier = _generateCodeVerifier();
|
||||
return PkceCodes(verifier: verifier, challenge: _challengeFor(verifier));
|
||||
}
|
||||
|
||||
static String _generateCodeVerifier() {
|
||||
final random = Random.secure();
|
||||
final bytes = List<int>.generate(32, (_) => random.nextInt(256));
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
static String _challengeFor(String verifier) {
|
||||
return sha256.convert(utf8.encode(verifier)).toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
/// Parses Firefox-compatible `user.js` text into a map of pref name to value.
|
||||
///
|
||||
/// Accepts the Firefox `user.js` subset used by WebLibre:
|
||||
/// - blank lines and whitespace
|
||||
/// - `//`, `#`, and `/* ... */` comments
|
||||
/// - `user_pref("name", value);`
|
||||
/// - string, boolean, and integer values
|
||||
///
|
||||
/// Duplicate pref names use last-write-wins semantics.
|
||||
class UserJsParseResult {
|
||||
final Map<String, Object> prefs;
|
||||
final int? schemaVersion;
|
||||
final String? exportedAt;
|
||||
|
||||
UserJsParseResult({required this.prefs, this.schemaVersion, this.exportedAt});
|
||||
}
|
||||
|
||||
UserJsParseResult parseUserJs(String text) {
|
||||
final parser = _UserJsParser(text);
|
||||
return parser.parse();
|
||||
}
|
||||
|
||||
class _UserJsParser {
|
||||
final String _text;
|
||||
int _index = 0;
|
||||
final Map<String, Object> _prefs = <String, Object>{};
|
||||
int? _schemaVersion;
|
||||
String? _exportedAt;
|
||||
|
||||
_UserJsParser(this._text);
|
||||
|
||||
UserJsParseResult parse() {
|
||||
while (true) {
|
||||
_skipTrivia();
|
||||
if (_isEof) {
|
||||
break;
|
||||
}
|
||||
|
||||
final identifier = _parseIdentifier();
|
||||
if (identifier != 'user_pref') {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_consumeChar('(')) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
final name = _parseStringLiteral();
|
||||
if (name == null) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_consumeChar(',')) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
final value = _parseValue();
|
||||
if (value == null) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_consumeChar(')')) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
_skipTrivia();
|
||||
if (!_consumeRawChar(';')) {
|
||||
_skipToStatementEnd();
|
||||
continue;
|
||||
}
|
||||
|
||||
_prefs[name] = value;
|
||||
}
|
||||
|
||||
return UserJsParseResult(
|
||||
prefs: _prefs,
|
||||
schemaVersion: _schemaVersion,
|
||||
exportedAt: _exportedAt,
|
||||
);
|
||||
}
|
||||
|
||||
bool get _isEof => _index >= _text.length;
|
||||
|
||||
String? _peek([int offset = 0]) {
|
||||
final position = _index + offset;
|
||||
if (position >= _text.length) {
|
||||
return null;
|
||||
}
|
||||
return _text[position];
|
||||
}
|
||||
|
||||
String? _advance() {
|
||||
if (_isEof) {
|
||||
return null;
|
||||
}
|
||||
return _text[_index++];
|
||||
}
|
||||
|
||||
bool _consumeRawChar(String char) {
|
||||
if (_peek() != char) {
|
||||
return false;
|
||||
}
|
||||
_index++;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _consumeChar(String char) {
|
||||
_skipTrivia();
|
||||
return _consumeRawChar(char);
|
||||
}
|
||||
|
||||
void _skipTrivia() {
|
||||
while (!_isEof) {
|
||||
final char = _peek();
|
||||
if (char == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isWhitespace(char)) {
|
||||
_advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char == '#') {
|
||||
_skipLineComment(isSlashComment: false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char == '/' && _peek(1) == '/') {
|
||||
_skipLineComment(isSlashComment: true);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char == '/' && _peek(1) == '*') {
|
||||
_skipBlockComment();
|
||||
continue;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _skipLineComment({required bool isSlashComment}) {
|
||||
final prefixLength = isSlashComment ? 2 : 1;
|
||||
final start = _index + prefixLength;
|
||||
_index += prefixLength;
|
||||
|
||||
while (!_isEof) {
|
||||
final char = _peek();
|
||||
if (char == '\n' || char == '\r') {
|
||||
break;
|
||||
}
|
||||
_index++;
|
||||
}
|
||||
|
||||
if (isSlashComment) {
|
||||
_parseMetadataComment(_text.substring(start, _index).trim());
|
||||
}
|
||||
|
||||
if (_peek() == '\r') {
|
||||
_index++;
|
||||
if (_peek() == '\n') {
|
||||
_index++;
|
||||
}
|
||||
} else if (_peek() == '\n') {
|
||||
_index++;
|
||||
}
|
||||
}
|
||||
|
||||
void _skipBlockComment() {
|
||||
_index += 2;
|
||||
while (!_isEof) {
|
||||
if (_peek() == '*' && _peek(1) == '/') {
|
||||
_index += 2;
|
||||
return;
|
||||
}
|
||||
_index++;
|
||||
}
|
||||
}
|
||||
|
||||
void _parseMetadataComment(String comment) {
|
||||
if (comment.startsWith('schema_version=')) {
|
||||
_schemaVersion = int.tryParse(
|
||||
comment.substring('schema_version='.length),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (comment.startsWith('exported_at=')) {
|
||||
_exportedAt = comment.substring('exported_at='.length);
|
||||
}
|
||||
}
|
||||
|
||||
String? _parseIdentifier() {
|
||||
_skipTrivia();
|
||||
final start = _index;
|
||||
while (!_isEof) {
|
||||
final char = _peek();
|
||||
if (char == null || !_isIdentifierChar(char)) {
|
||||
break;
|
||||
}
|
||||
_index++;
|
||||
}
|
||||
if (_index == start) {
|
||||
return null;
|
||||
}
|
||||
return _text.substring(start, _index);
|
||||
}
|
||||
|
||||
Object? _parseValue() {
|
||||
_skipTrivia();
|
||||
final char = _peek();
|
||||
if (char == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (char == '"' || char == "'") {
|
||||
return _parseStringLiteral();
|
||||
}
|
||||
|
||||
if (char == 't' || char == 'f') {
|
||||
final identifier = _parseIdentifier();
|
||||
if (identifier == 'true') {
|
||||
return true;
|
||||
}
|
||||
if (identifier == 'false') {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return _parseIntLiteral();
|
||||
}
|
||||
|
||||
int? _parseIntLiteral() {
|
||||
_skipTrivia();
|
||||
final start = _index;
|
||||
|
||||
var sign = 1;
|
||||
if (_peek() == '+') {
|
||||
_index++;
|
||||
} else if (_peek() == '-') {
|
||||
sign = -1;
|
||||
_index++;
|
||||
}
|
||||
|
||||
// Firefox tokenizes +/- separately, then skips whitespace/comments before
|
||||
// reading the integer literal.
|
||||
_skipTrivia();
|
||||
|
||||
final digitStart = _index;
|
||||
while (!_isEof) {
|
||||
final char = _peek();
|
||||
if (char == null || !_isDigit(char)) {
|
||||
break;
|
||||
}
|
||||
_index++;
|
||||
}
|
||||
|
||||
if (_index == digitStart) {
|
||||
_index = start;
|
||||
return null;
|
||||
}
|
||||
|
||||
final trailing = _peek();
|
||||
if (trailing != null && _isIdentifierChar(trailing)) {
|
||||
_index = start;
|
||||
return null;
|
||||
}
|
||||
|
||||
final digits = _text.substring(digitStart, _index);
|
||||
final value = int.tryParse(digits);
|
||||
if (value == null) {
|
||||
_index = start;
|
||||
return null;
|
||||
}
|
||||
|
||||
final signedValue = sign * value;
|
||||
if (signedValue < -2147483648 || signedValue > 2147483647) {
|
||||
_index = start;
|
||||
return null;
|
||||
}
|
||||
|
||||
return signedValue;
|
||||
}
|
||||
|
||||
String? _parseStringLiteral() {
|
||||
_skipTrivia();
|
||||
final quote = _peek();
|
||||
if (quote != '"' && quote != "'") {
|
||||
return null;
|
||||
}
|
||||
|
||||
_index++;
|
||||
final buffer = StringBuffer();
|
||||
|
||||
while (!_isEof) {
|
||||
final char = _advance();
|
||||
if (char == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (char == quote) {
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
if (char != '\\') {
|
||||
buffer.write(char);
|
||||
continue;
|
||||
}
|
||||
|
||||
final escaped = _parseEscape();
|
||||
if (escaped == null) {
|
||||
return null;
|
||||
}
|
||||
buffer.write(escaped);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _parseEscape() {
|
||||
final char = _advance();
|
||||
switch (char) {
|
||||
case '"':
|
||||
return '"';
|
||||
case "'":
|
||||
return "'";
|
||||
case '\\':
|
||||
return '\\';
|
||||
case 'n':
|
||||
return '\n';
|
||||
case 'r':
|
||||
return '\r';
|
||||
case 'x':
|
||||
final value = _parseHexValue(length: 2);
|
||||
if (value == null || value == 0) {
|
||||
return null;
|
||||
}
|
||||
return String.fromCharCode(value);
|
||||
case 'u':
|
||||
final value = _parseUnicodeEscape();
|
||||
if (value == null || value == 0) {
|
||||
return null;
|
||||
}
|
||||
return String.fromCharCode(value);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int? _parseUnicodeEscape() {
|
||||
final value = _parseHexValue(length: 4);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_isHighSurrogate(value)) {
|
||||
if (_advance() != '\\' || _advance() != 'u') {
|
||||
return null;
|
||||
}
|
||||
final lowValue = _parseHexValue(length: 4);
|
||||
if (lowValue == null || !_isLowSurrogate(lowValue)) {
|
||||
return null;
|
||||
}
|
||||
return 0x10000 + ((value - 0xD800) << 10) + (lowValue - 0xDC00);
|
||||
}
|
||||
|
||||
if (_isLowSurrogate(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
int? _parseHexValue({required int length}) {
|
||||
var value = 0;
|
||||
for (var i = 0; i < length; i++) {
|
||||
final char = _advance();
|
||||
final digit = char == null ? null : _hexDigitValue(char);
|
||||
if (digit == null) {
|
||||
return null;
|
||||
}
|
||||
value = (value << 4) + digit;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
int? _hexDigitValue(String char) {
|
||||
final code = char.codeUnitAt(0);
|
||||
if (code >= 0x30 && code <= 0x39) {
|
||||
return code - 0x30;
|
||||
}
|
||||
if (code >= 0x41 && code <= 0x46) {
|
||||
return code - 0x41 + 10;
|
||||
}
|
||||
if (code >= 0x61 && code <= 0x66) {
|
||||
return code - 0x61 + 10;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _skipToStatementEnd() {
|
||||
while (!_isEof) {
|
||||
final char = _advance();
|
||||
if (char == ';') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _isWhitespace(String char) {
|
||||
return char == ' ' ||
|
||||
char == '\t' ||
|
||||
char == '\n' ||
|
||||
char == '\r' ||
|
||||
char == '\v' ||
|
||||
char == '\f';
|
||||
}
|
||||
|
||||
bool _isIdentifierChar(String char) {
|
||||
final code = char.codeUnitAt(0);
|
||||
return (code >= 0x41 && code <= 0x5A) ||
|
||||
(code >= 0x61 && code <= 0x7A) ||
|
||||
(code >= 0x30 && code <= 0x39) ||
|
||||
code == 0x5F;
|
||||
}
|
||||
|
||||
bool _isDigit(String char) {
|
||||
final code = char.codeUnitAt(0);
|
||||
return code >= 0x30 && code <= 0x39;
|
||||
}
|
||||
|
||||
bool _isHighSurrogate(int value) => value >= 0xD800 && value <= 0xDBFF;
|
||||
|
||||
bool _isLowSurrogate(int value) => value >= 0xDC00 && value <= 0xDFFF;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
/// Unsyncable URL-style pref value prefixes (inspired by Firefox Sync).
|
||||
const unsyncablePrefPrefixes = ['moz-extension:', 'blob:', 'data:', 'file:'];
|
||||
|
||||
/// Returns true if a persisted Gecko pref value is syncable (supported scalar
|
||||
/// type and not an unsyncable URL-style string value).
|
||||
bool isSyncablePrefValue(Object value) {
|
||||
if (value is bool || value is int) return true;
|
||||
if (value is String) {
|
||||
return !unsyncablePrefPrefixes.any(value.startsWith);
|
||||
}
|
||||
// Unsupported type (e.g. double, list)
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Serializes persisted Gecko user prefs into canonical `user.js` text.
|
||||
///
|
||||
/// [userPrefs] is the map of user-set prefs parsed from the active Gecko
|
||||
/// profile's `prefs.js` file.
|
||||
///
|
||||
/// String-valued prefs starting with unsyncable URL prefixes are excluded.
|
||||
String serializeUserJs({
|
||||
required Map<String, Object> userPrefs,
|
||||
required int schemaVersion,
|
||||
String? exportedAt,
|
||||
}) {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('// WebLibre Gecko prefs snapshot');
|
||||
buffer.writeln('// schema_version=$schemaVersion');
|
||||
buffer.writeln(
|
||||
'// exported_at=${exportedAt ?? DateTime.now().toUtc().toIso8601String()}',
|
||||
);
|
||||
|
||||
final syncable =
|
||||
userPrefs.entries.where((e) => isSyncablePrefValue(e.value)).toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
|
||||
for (final entry in syncable) {
|
||||
final literal = _toLiteral(entry.value);
|
||||
if (literal == null) continue;
|
||||
final escapedName = _escapeString(entry.key);
|
||||
buffer.writeln('user_pref("$escapedName", $literal);');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String? _toLiteral(Object? value) {
|
||||
if (value is bool) return value.toString();
|
||||
if (value is int) return value.toString();
|
||||
if (value is String) return '"${_escapeString(value)}"';
|
||||
return null;
|
||||
}
|
||||
|
||||
String _escapeString(String value) {
|
||||
return value
|
||||
.replaceAll('\\', r'\\')
|
||||
.replaceAll('"', r'\"')
|
||||
.replaceAll('\n', r'\n')
|
||||
.replaceAll('\r', r'\r');
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* 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:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/providers/device_info.dart';
|
||||
import 'package:weblibre/features/about/domain/providers.dart';
|
||||
import 'package:weblibre/features/account/data/models/account_auth_state.dart';
|
||||
import 'package:weblibre/features/account/data/models/subscription_status.dart';
|
||||
import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart';
|
||||
import 'package:weblibre/features/account/domain/repositories/account_auth.dart';
|
||||
import 'package:weblibre/features/account/domain/repositories/subscription_repository.dart';
|
||||
import 'package:weblibre/features/account/domain/services/prefs_sync_service.dart';
|
||||
import 'package:weblibre/features/account/domain/services/settings_sync_service.dart';
|
||||
import 'package:weblibre/features/account/presentation/widgets/account_auth_status_card.dart';
|
||||
import 'package:weblibre/features/account/presentation/widgets/subscription_card.dart';
|
||||
import 'package:weblibre/features/account/presentation/widgets/sync_document_list_section.dart';
|
||||
import 'package:weblibre/features/account/presentation/widgets/sync_setup_card.dart';
|
||||
import 'package:weblibre/features/search_credits/presentation/widgets/search_credits_section.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
|
||||
class AccountSettingsScreen extends HookConsumerWidget {
|
||||
const AccountSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authAsync = ref.watch(accountAuthRepositoryProvider);
|
||||
final subscriptionAsync = ref.watch(subscriptionRepositoryProvider);
|
||||
final search = useSettingsSearch();
|
||||
|
||||
Widget buildBody(Widget sliver) {
|
||||
return SettingsCustomScrollScaffold(
|
||||
title: 'WebLibre Account',
|
||||
searchController: search.controller,
|
||||
searchHintText: 'Search account settings',
|
||||
slivers: [sliver],
|
||||
);
|
||||
}
|
||||
|
||||
return authAsync.when(
|
||||
loading: () => buildBody(
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
error: (_, _) => buildBody(
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: Text('Failed to load account')),
|
||||
),
|
||||
),
|
||||
data: (authState) {
|
||||
final sections = _buildSections(
|
||||
ref: ref,
|
||||
authState: authState,
|
||||
subscriptionAsync: subscriptionAsync,
|
||||
);
|
||||
|
||||
final filteredSections = filterSettingsSections(
|
||||
sections: sections,
|
||||
query: search.rawQuery,
|
||||
);
|
||||
|
||||
return buildBody(
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 20),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: SettingsSectionList(
|
||||
sections: filteredSections,
|
||||
query: search.rawQuery,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<SettingsSectionDefinition> _buildSections({
|
||||
required WidgetRef ref,
|
||||
required AccountAuthState authState,
|
||||
required AsyncValue<SubscriptionStatus> subscriptionAsync,
|
||||
}) {
|
||||
final showSyncSnapshots =
|
||||
authState.isSignedIn && subscriptionAsync.value?.isActive == true;
|
||||
final syncClient = showSyncSnapshots
|
||||
? ref.read(accountSyncRepositoryProvider)
|
||||
: null;
|
||||
final syncRepo = syncClient != null
|
||||
? ref.read(accountSyncRepositoryProvider.notifier)
|
||||
: null;
|
||||
final sourceDeviceId = ref.read(androidDeviceInfoProvider).value?.deviceName;
|
||||
final sourceAppVersion = _appVersion(ref);
|
||||
|
||||
return <SettingsSectionDefinition>[
|
||||
SettingsSectionDefinition(
|
||||
title: 'Account',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: switch (authState.status) {
|
||||
AccountAuthStatus.signedOut => 'Sign in to WebLibre Account',
|
||||
AccountAuthStatus.signingIn => 'Signing in',
|
||||
AccountAuthStatus.signedIn => 'Signed in account',
|
||||
AccountAuthStatus.error => 'Sign-in failed',
|
||||
},
|
||||
subtitle: switch (authState.status) {
|
||||
AccountAuthStatus.signedOut => 'Sync your settings across devices',
|
||||
AccountAuthStatus.signingIn => 'Complete sign-in in your browser',
|
||||
AccountAuthStatus.signedIn =>
|
||||
authState.displayName ?? authState.email ?? 'Signed in',
|
||||
AccountAuthStatus.error => authState.lastError,
|
||||
},
|
||||
keywords: [
|
||||
'sign in',
|
||||
'account',
|
||||
'authentication',
|
||||
if (authState.hasSyncKey) ...['sync key', 'reset sync key'],
|
||||
],
|
||||
child: AccountAuthStatusCard(authState: authState),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (authState.isSignedIn)
|
||||
SettingsSectionDefinition(
|
||||
title: 'Subscription',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Supporter subscription',
|
||||
subtitle: 'Status, billing, and subscription management',
|
||||
keywords: const ['billing', 'supporter'],
|
||||
child: SubscriptionCard(subscriptionAsync: subscriptionAsync),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (authState.isSignedIn)
|
||||
const SettingsSectionDefinition(
|
||||
title: 'Search Credits',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Search credits',
|
||||
subtitle: 'Credits balance, token issuance, and purchases',
|
||||
keywords: ['tokens', 'search pack'],
|
||||
child: SearchCreditsSection(embedded: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showSyncSnapshots && syncRepo != null)
|
||||
if (authState.hasSyncKey) ...[
|
||||
SettingsSectionDefinition(
|
||||
title: 'Settings Snapshots',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Settings snapshots',
|
||||
subtitle: 'Store and restore synced application settings',
|
||||
keywords: const ['backups', 'settings sync'],
|
||||
child: SyncDocumentListSection(
|
||||
service: ref.read(settingsSyncServiceProvider.notifier),
|
||||
syncRepo: syncRepo,
|
||||
syncKey: authState.syncKey!,
|
||||
sourceDeviceId: sourceDeviceId,
|
||||
sourceAppVersion: sourceAppVersion,
|
||||
embedded: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'Preferences Snapshots',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Preferences snapshots',
|
||||
subtitle: 'Store and restore synced preference documents',
|
||||
keywords: const ['backups', 'prefs sync'],
|
||||
child: SyncDocumentListSection(
|
||||
service: ref.read(prefsSyncServiceProvider.notifier),
|
||||
syncRepo: syncRepo,
|
||||
syncKey: authState.syncKey!,
|
||||
sourceDeviceId: sourceDeviceId,
|
||||
sourceAppVersion: sourceAppVersion,
|
||||
embedded: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else
|
||||
SettingsSectionDefinition(
|
||||
title: 'Encrypted Sync',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Set up encrypted sync',
|
||||
subtitle:
|
||||
'Enable end-to-end encrypted sync using your account password',
|
||||
keywords: const ['sync key', 'backups', 'snapshots'],
|
||||
child: SyncSetupCard(email: authState.email),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
String? _appVersion(WidgetRef ref) {
|
||||
final info = ref.read(packageInfoProvider).value;
|
||||
if (info == null) return null;
|
||||
return '${info.version}+${info.buildNumber}';
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
* 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:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/account/data/models/account_auth_state.dart';
|
||||
import 'package:weblibre/features/account/domain/repositories/account_auth.dart';
|
||||
|
||||
/// Body of the "Account" settings entry. Renders the auth state machine:
|
||||
/// signed-out CTA, signing-in spinner with cancel, signed-in identity with
|
||||
/// sign-out + sync-key reset, or an error tile with retry.
|
||||
class AccountAuthStatusCard extends ConsumerWidget {
|
||||
const AccountAuthStatusCard({super.key, required this.authState});
|
||||
|
||||
final AccountAuthState authState;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return switch (authState.status) {
|
||||
AccountAuthStatus.signedOut => const _SignedOutTile(),
|
||||
AccountAuthStatus.signingIn => const _SigningInTile(),
|
||||
AccountAuthStatus.signedIn => _SignedInTile(authState: authState),
|
||||
AccountAuthStatus.error => _ErrorTile(authState: authState),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _SignedOutTile extends ConsumerWidget {
|
||||
const _SignedOutTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.login),
|
||||
title: const Text('Sign in to WebLibre Account'),
|
||||
subtitle: const Text('Sync your settings across devices'),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
horizontal: 16.0,
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
await ref.read(accountAuthRepositoryProvider.notifier).startSignIn();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SigningInTile extends ConsumerWidget {
|
||||
const _SigningInTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Signing in...'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Complete sign-in in your browser',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(accountAuthRepositoryProvider.notifier)
|
||||
.cancelSignIn();
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SignedInTile extends ConsumerWidget {
|
||||
const _SignedInTile({required this.authState});
|
||||
|
||||
final AccountAuthState authState;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.account_circle),
|
||||
title: Text(authState.displayName ?? authState.email ?? 'Signed in'),
|
||||
subtitle:
|
||||
authState.email != null &&
|
||||
authState.email != authState.displayName
|
||||
? Text(authState.email!)
|
||||
: null,
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
tooltip: 'Sign Out',
|
||||
onPressed: () async {
|
||||
final confirmed = await _showSignOutConfirmation(context);
|
||||
if (confirmed == true) {
|
||||
await ref
|
||||
.read(accountAuthRepositoryProvider.notifier)
|
||||
.signOut();
|
||||
}
|
||||
},
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
horizontal: 16.0,
|
||||
),
|
||||
),
|
||||
if (authState.hasSyncKey) ...[
|
||||
const Divider(height: 1),
|
||||
const _ResetSyncKeyTile(),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static Future<bool?> _showSignOutConfirmation(BuildContext context) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Sign out?'),
|
||||
content: const Text(
|
||||
'Are you sure you want to sign out of your WebLibre Account?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Sign Out'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorTile extends ConsumerWidget {
|
||||
const _ErrorTile({required this.authState});
|
||||
|
||||
final AccountAuthState authState;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Sign-in failed',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (authState.lastError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
authState.lastError!,
|
||||
style: TextStyle(color: colorScheme.onErrorContainer),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.tonal(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(accountAuthRepositoryProvider.notifier)
|
||||
.startSignIn();
|
||||
},
|
||||
child: const Text('Try Again'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResetSyncKeyTile extends ConsumerWidget {
|
||||
const _ResetSyncKeyTile();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.key_off_outlined),
|
||||
title: const Text('Reset Sync Key'),
|
||||
subtitle: const Text(
|
||||
'Re-enter your password if you mistyped it or changed it',
|
||||
),
|
||||
onTap: () async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Reset Sync Key'),
|
||||
content: const Text(
|
||||
'You will need to re-enter your account password. '
|
||||
'If your password changed, existing snapshots '
|
||||
'encrypted with the old password will no longer '
|
||||
'be decryptable.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Reset'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await ref.read(accountAuthRepositoryProvider.notifier).clearSyncKey();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
* 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:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:weblibre/features/account/data/models/subscription_status.dart';
|
||||
import 'package:weblibre/features/account/data/supabase_config.dart';
|
||||
import 'package:weblibre/features/account/domain/repositories/subscription_repository.dart';
|
||||
|
||||
/// Visual presentation of one subscription state. All branches of the
|
||||
/// subscription UI render through the same ListTile + badge + note + manage
|
||||
/// button structure — the differences boil down to these fields, which the
|
||||
/// state machine selects in `_resolvePresentation`.
|
||||
class _SubscriptionPresentation {
|
||||
final IconData leadingIcon;
|
||||
final Color? leadingIconColor;
|
||||
final String planTitle;
|
||||
final String badgeLabel;
|
||||
final Color badgeColor;
|
||||
final Color badgeTextColor;
|
||||
final String? note;
|
||||
final Color? noteColor;
|
||||
final String manageLabel;
|
||||
final String? subtitle;
|
||||
final DateTime? expiryHint;
|
||||
|
||||
const _SubscriptionPresentation({
|
||||
required this.leadingIcon,
|
||||
required this.planTitle,
|
||||
required this.badgeLabel,
|
||||
required this.badgeColor,
|
||||
required this.badgeTextColor,
|
||||
required this.manageLabel,
|
||||
this.leadingIconColor,
|
||||
this.note,
|
||||
this.noteColor,
|
||||
this.subtitle,
|
||||
this.expiryHint,
|
||||
});
|
||||
}
|
||||
|
||||
class SubscriptionCard extends HookConsumerWidget {
|
||||
const SubscriptionCard({super.key, required this.subscriptionAsync});
|
||||
|
||||
final AsyncValue<SubscriptionStatus> subscriptionAsync;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Refresh on resume regardless of which branch is currently rendered.
|
||||
// Keeping the hook at the top level means a stale error tile (e.g. the
|
||||
// user opened the screen offline, then reconnected and returned to the
|
||||
// app) still gets a fresh fetch — previously the hook only ran in the
|
||||
// `data` branch and never fired from the error state.
|
||||
useOnAppLifecycleStateChange((previous, current) async {
|
||||
if (current == AppLifecycleState.resumed) {
|
||||
await ref.read(subscriptionRepositoryProvider.notifier).refresh();
|
||||
}
|
||||
});
|
||||
|
||||
return subscriptionAsync.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
// Render a dedicated error tile so the user can tell "fetch failed"
|
||||
// apart from "no subscription" — they have different fixes (retry
|
||||
// vs. subscribe).
|
||||
error: (_, _) => _SubscriptionErrorTile(
|
||||
onRetry: () =>
|
||||
ref.read(subscriptionRepositoryProvider.notifier).refresh(),
|
||||
),
|
||||
data: (status) {
|
||||
final presentation = _resolvePresentation(context, status);
|
||||
return _SubscriptionStateBody(presentation: presentation);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SubscriptionErrorTile extends StatelessWidget {
|
||||
const _SubscriptionErrorTile({required this.onRetry});
|
||||
|
||||
final Future<void> Function() onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
leading: Icon(Icons.error_outline, color: scheme.error),
|
||||
title: const Text('Could not load subscription'),
|
||||
subtitle: const Text('Check your connection and try again.'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Retry',
|
||||
onPressed: onRetry,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_SubscriptionPresentation _resolvePresentation(
|
||||
BuildContext context,
|
||||
SubscriptionStatus status,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final planTitle = status.planLabel ?? 'Supporter';
|
||||
|
||||
if (status.isActive) {
|
||||
final isWindingDown = status.isWindingDown;
|
||||
final endDate = status.currentPeriodEnd ?? status.entitledUntil;
|
||||
return _SubscriptionPresentation(
|
||||
leadingIcon: Icons.verified,
|
||||
leadingIconColor: scheme.primary,
|
||||
planTitle: planTitle,
|
||||
badgeLabel: isWindingDown ? 'Will not renew' : 'Active',
|
||||
badgeColor: isWindingDown
|
||||
? scheme.surfaceContainerHighest
|
||||
: scheme.primaryContainer,
|
||||
badgeTextColor: isWindingDown
|
||||
? scheme.onSurface
|
||||
: scheme.onPrimaryContainer,
|
||||
subtitle: status.entitledUntil != null
|
||||
? 'Until ${_formatDate(status.entitledUntil!)}'
|
||||
: null,
|
||||
expiryHint: isWindingDown ? endDate : null,
|
||||
manageLabel: 'Manage Subscription',
|
||||
);
|
||||
}
|
||||
if (status.isPaused) {
|
||||
return _SubscriptionPresentation(
|
||||
leadingIcon: Icons.pause_circle_outline,
|
||||
leadingIconColor: scheme.onSurfaceVariant,
|
||||
planTitle: planTitle,
|
||||
badgeLabel: 'Paused',
|
||||
badgeColor: scheme.tertiaryContainer,
|
||||
badgeTextColor: scheme.onTertiaryContainer,
|
||||
note:
|
||||
'Your subscription is paused. Resume it from the customer '
|
||||
'portal to restore access.',
|
||||
noteColor: scheme.onSurfaceVariant,
|
||||
manageLabel: 'Manage Subscription',
|
||||
);
|
||||
}
|
||||
if (status.isPastDue) {
|
||||
return _SubscriptionPresentation(
|
||||
leadingIcon: Icons.error_outline,
|
||||
leadingIconColor: scheme.onSurfaceVariant,
|
||||
planTitle: planTitle,
|
||||
badgeLabel: 'Past due',
|
||||
badgeColor: scheme.errorContainer,
|
||||
badgeTextColor: scheme.onErrorContainer,
|
||||
note:
|
||||
'Payment failed. Update your payment method to keep your '
|
||||
'subscription active.',
|
||||
noteColor: scheme.error,
|
||||
manageLabel: 'Update Payment Method',
|
||||
);
|
||||
}
|
||||
if (status.isWindingDown) {
|
||||
// No active entitlement remains (isActive was false above) — the
|
||||
// grace period has ended or there never was one. Offer renewal.
|
||||
return _SubscriptionPresentation(
|
||||
leadingIcon: Icons.history_toggle_off,
|
||||
leadingIconColor: scheme.onSurfaceVariant,
|
||||
planTitle: planTitle,
|
||||
badgeLabel: 'Will not renew',
|
||||
badgeColor: scheme.surfaceContainerHighest,
|
||||
badgeTextColor: scheme.onSurface,
|
||||
note:
|
||||
'Your subscription has ended. Renew from the customer '
|
||||
'portal to continue.',
|
||||
noteColor: scheme.onSurfaceVariant,
|
||||
manageLabel: 'Renew Subscription',
|
||||
);
|
||||
}
|
||||
return _inactivePresentation(context);
|
||||
}
|
||||
|
||||
_SubscriptionPresentation _inactivePresentation(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return _SubscriptionPresentation(
|
||||
leadingIcon: Icons.card_membership,
|
||||
leadingIconColor: scheme.onSurfaceVariant,
|
||||
planTitle: 'Supporter Subscription',
|
||||
subtitle: 'Subscribe to unlock sync features',
|
||||
badgeLabel: 'Inactive',
|
||||
badgeColor: scheme.surfaceContainerHighest,
|
||||
badgeTextColor: scheme.onSurface,
|
||||
manageLabel: 'Subscribe',
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) => DateFormat.yMMMd().format(date);
|
||||
|
||||
class _SubscriptionStateBody extends ConsumerWidget {
|
||||
const _SubscriptionStateBody({required this.presentation});
|
||||
|
||||
final _SubscriptionPresentation presentation;
|
||||
|
||||
Future<void> _openPortal() async {
|
||||
await launchUrl(
|
||||
Uri.parse(SupabaseConfig.accountWebUrl),
|
||||
mode: LaunchMode.inAppBrowserView,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
presentation.leadingIcon,
|
||||
color: presentation.leadingIconColor,
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Text(presentation.planTitle),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: presentation.badgeColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
presentation.badgeLabel,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: presentation.badgeTextColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: presentation.subtitle != null
|
||||
? Text(presentation.subtitle!)
|
||||
: null,
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh status',
|
||||
onPressed: () async {
|
||||
await ref.read(subscriptionRepositoryProvider.notifier).refresh();
|
||||
},
|
||||
),
|
||||
),
|
||||
if (presentation.expiryHint != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, size: 16, color: scheme.error),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Your subscription will end on '
|
||||
'${_formatDate(presentation.expiryHint!)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (presentation.note != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Text(
|
||||
presentation.note!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: presentation.noteColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.open_in_new),
|
||||
title: Text(presentation.manageLabel),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
onTap: _openPortal,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* 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:flutter/material.dart';
|
||||
import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart';
|
||||
|
||||
// -- Metadata display helpers ------------------------------------------------
|
||||
|
||||
class MetadataRow extends StatelessWidget {
|
||||
const MetadataRow({super.key, required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value, style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String formatDateTime(DateTime dt) {
|
||||
final local = dt.toLocal();
|
||||
return '${local.year}-${_pad(local.month)}-${_pad(local.day)} '
|
||||
'${_pad(local.hour)}:${_pad(local.minute)}';
|
||||
}
|
||||
|
||||
String _pad(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
// -- Dialogs -----------------------------------------------------------------
|
||||
|
||||
Future<String?> showStoreLabelDialog(BuildContext context) {
|
||||
final controller = TextEditingController();
|
||||
|
||||
return showDialog<String?>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Store Snapshot'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Label (optional)',
|
||||
hintText: 'e.g. "Before update", "Home setup"',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final label = controller.text.trim();
|
||||
Navigator.of(context).pop(label.isEmpty ? '' : label);
|
||||
},
|
||||
child: const Text('Store'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> showEditLabelDialog(
|
||||
BuildContext context, {
|
||||
String? currentLabel,
|
||||
}) {
|
||||
final controller = TextEditingController(text: currentLabel);
|
||||
|
||||
return showDialog<String?>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Edit Label'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Label',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final label = controller.text.trim();
|
||||
Navigator.of(context).pop(label.isEmpty ? '' : label);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool?> showRestoreConfirmation(
|
||||
BuildContext context, {
|
||||
required SyncDocumentMetadata metadata,
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Restore Snapshot'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('This will overwrite your current local settings.'),
|
||||
const SizedBox(height: 16),
|
||||
if (metadata.label != null && metadata.label!.isNotEmpty)
|
||||
MetadataRow(label: 'Label', value: metadata.label!),
|
||||
MetadataRow(
|
||||
label: 'Stored',
|
||||
value: formatDateTime(metadata.updatedAt),
|
||||
),
|
||||
if (metadata.sourceAppVersion != null)
|
||||
MetadataRow(
|
||||
label: 'App version',
|
||||
value: metadata.sourceAppVersion!,
|
||||
),
|
||||
if (metadata.sourceDeviceId != null)
|
||||
MetadataRow(label: 'Device', value: metadata.sourceDeviceId!),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Restore'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool?> showDeleteConfirmation(
|
||||
BuildContext context, {
|
||||
required SyncDocumentMetadata metadata,
|
||||
}) {
|
||||
final label = metadata.label?.isNotEmpty == true
|
||||
? '"${metadata.label}"'
|
||||
: 'this snapshot';
|
||||
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Snapshot'),
|
||||
content: Text('Are you sure you want to delete $label?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
/*
|
||||
* 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 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:secure_archive/secure_archive.dart';
|
||||
import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart';
|
||||
import 'package:weblibre/features/account/domain/services/sync_document_service.dart';
|
||||
import 'package:weblibre/features/account/presentation/widgets/sync_document_dialogs.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_content_card.dart';
|
||||
|
||||
class SyncDocumentListSection extends HookWidget {
|
||||
const SyncDocumentListSection({
|
||||
super.key,
|
||||
required this.service,
|
||||
required this.syncRepo,
|
||||
required this.syncKey,
|
||||
this.sourceDeviceId,
|
||||
this.sourceAppVersion,
|
||||
this.embedded = false,
|
||||
});
|
||||
|
||||
final SyncDocumentService service;
|
||||
final AccountSyncRepository syncRepo;
|
||||
final String syncKey;
|
||||
final String? sourceDeviceId;
|
||||
final String? sourceAppVersion;
|
||||
final bool embedded;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final documents = useState<List<SyncDocumentMetadata>?>(null);
|
||||
final loading = useState(true);
|
||||
final busy = useState(false);
|
||||
|
||||
final secureData = useMemoized(
|
||||
() => SecureData(argon2Params: Argon2Params.memoryConstrained()),
|
||||
);
|
||||
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
final docs = await syncRepo.listDocuments(kind: service.kind);
|
||||
documents.value = docs;
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to load snapshots: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
unawaited(refresh());
|
||||
return null;
|
||||
}, const []);
|
||||
|
||||
Future<void> storeCurrent() async {
|
||||
final labelResult = await showStoreLabelDialog(context);
|
||||
if (labelResult == null) return;
|
||||
|
||||
busy.value = true;
|
||||
try {
|
||||
final plaintext = await service.serializeCurrent();
|
||||
final encrypted = await secureData.encrypt(
|
||||
plaintext,
|
||||
syncKey,
|
||||
compress: true,
|
||||
);
|
||||
final blob = base64Encode(encrypted);
|
||||
final label = labelResult.isEmpty ? null : labelResult;
|
||||
|
||||
await syncRepo.storeDocument(
|
||||
kind: service.kind,
|
||||
schemaVersion: service.schemaVersion,
|
||||
contentBlob: blob,
|
||||
label: label,
|
||||
sourceDeviceId: sourceDeviceId,
|
||||
sourceAppVersion: sourceAppVersion,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${service.kind.displayName} stored')),
|
||||
);
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to store: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> restore(SyncDocumentMetadata metadata) async {
|
||||
final confirmed = await showRestoreConfirmation(
|
||||
context,
|
||||
metadata: metadata,
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
busy.value = true;
|
||||
try {
|
||||
final result = await syncRepo.fetchDocument(id: metadata.id);
|
||||
if (result == null) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Snapshot not found')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final encrypted = base64Decode(result.contentBlob);
|
||||
final plaintext = await secureData.decrypt(encrypted, syncKey);
|
||||
await service.applyRestored(plaintext);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${service.kind.displayName} restored')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
if (_isDecryptionFailure(e)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Decryption failed — wrong password or data corrupted. '
|
||||
'Try resetting your sync key.',
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to restore: $e')));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> editLabel(SyncDocumentMetadata metadata) async {
|
||||
final newLabel = await showEditLabelDialog(
|
||||
context,
|
||||
currentLabel: metadata.label,
|
||||
);
|
||||
if (newLabel == null) return;
|
||||
|
||||
try {
|
||||
await syncRepo.updateLabel(
|
||||
id: metadata.id,
|
||||
label: newLabel.isEmpty ? null : newLabel,
|
||||
);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to update label: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(SyncDocumentMetadata metadata) async {
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
metadata: metadata,
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
try {
|
||||
await syncRepo.deleteDocument(id: metadata.id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Snapshot deleted')));
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to delete: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
'${service.kind.displayName} Snapshots',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: busy.value
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.cloud_upload_outlined),
|
||||
title: const Text('Store Current'),
|
||||
subtitle: Text(
|
||||
'Encrypt and upload current ${service.kind.displayName.toLowerCase()}',
|
||||
),
|
||||
enabled: !busy.value,
|
||||
onTap: storeCurrent,
|
||||
),
|
||||
if (loading.value)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (documents.value == null || documents.value!.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
child: Text(
|
||||
'No snapshots stored yet',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
const Divider(height: 1),
|
||||
for (final doc in documents.value!)
|
||||
_DocumentTile(
|
||||
metadata: doc,
|
||||
busy: busy.value,
|
||||
onRestore: () => restore(doc),
|
||||
onEditLabel: () => editLabel(doc),
|
||||
onDelete: () => delete(doc),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return SettingsContentCard(embedded: embedded, child: content);
|
||||
}
|
||||
}
|
||||
|
||||
class _DocumentTile extends StatelessWidget {
|
||||
const _DocumentTile({
|
||||
required this.metadata,
|
||||
required this.busy,
|
||||
required this.onRestore,
|
||||
required this.onEditLabel,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final SyncDocumentMetadata metadata;
|
||||
final bool busy;
|
||||
final VoidCallback onRestore;
|
||||
final VoidCallback onEditLabel;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = metadata.label?.isNotEmpty == true
|
||||
? metadata.label!
|
||||
: 'Untitled';
|
||||
final subtitle = StringBuffer(formatDateTime(metadata.updatedAt));
|
||||
if (metadata.sourceDeviceId != null) {
|
||||
subtitle.write(' · ${metadata.sourceDeviceId}');
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.description_outlined),
|
||||
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
subtitle.toString(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: MenuAnchor(
|
||||
builder: (context, controller, _) => IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onPressed: busy
|
||||
? null
|
||||
: () =>
|
||||
controller.isOpen ? controller.close() : controller.open(),
|
||||
),
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(Icons.cloud_download_outlined),
|
||||
onPressed: onRestore,
|
||||
child: const Text('Restore'),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(Icons.edit_outlined),
|
||||
onPressed: onEditLabel,
|
||||
child: const Text('Edit Label'),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
Icons.delete_outlined,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
onPressed: onDelete,
|
||||
child: Text(
|
||||
'Delete',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical secure_archive error message fragments that all map to
|
||||
/// "the candidate key cannot open this blob" (wrong password, MAC failure,
|
||||
/// corrupted ciphertext). Sourced from `secure_archive/lib/src/data/`:
|
||||
///
|
||||
/// - "wrong password or corrupted data" — from `secure_data.dart` (thrown
|
||||
/// on `SecretBoxAuthenticationError` from chacha20-poly1305 MAC check).
|
||||
/// - "Could not validate backup integrity" — from the archive layer.
|
||||
/// - "Corrupt output" — from the streaming gzip decoder.
|
||||
///
|
||||
/// If `secure_archive` upstream switches to typed exceptions, prefer
|
||||
/// catching the type and delete these strings.
|
||||
const _secureArchiveDecryptionFailureFragments = <String>{
|
||||
'wrong password or corrupted data',
|
||||
'Could not validate backup integrity',
|
||||
'Corrupt output',
|
||||
};
|
||||
|
||||
/// True when [error] indicates the candidate sync key could not open the
|
||||
/// snapshot — either it's wrong, the envelope is corrupted, or the format
|
||||
/// version isn't recognised. Used to present a key/integrity problem
|
||||
/// instead of a generic failure.
|
||||
///
|
||||
/// `FormatException` covers header-level failures (unsupported version,
|
||||
/// bad framing), which `secure_archive` raises before any crypto runs.
|
||||
/// Other `Exception` instances are sniffed by message contents — see
|
||||
/// [_secureArchiveDecryptionFailureFragments] for the contract.
|
||||
bool _isDecryptionFailure(Object error) {
|
||||
if (error is FormatException) return true;
|
||||
if (error is Exception) {
|
||||
final message = error.toString();
|
||||
return _secureArchiveDecryptionFailureFragments.any(message.contains);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* 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 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:secure_archive/secure_archive.dart';
|
||||
import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart';
|
||||
import 'package:weblibre/features/account/domain/repositories/account_auth.dart';
|
||||
|
||||
/// Lets a signed-in user derive an end-to-end encryption key from their
|
||||
/// account password.
|
||||
///
|
||||
/// Two safety nets keep a mistyped password from silently locking the
|
||||
/// user out of future restores:
|
||||
///
|
||||
/// 1. **Confirm-password field.** Same password must be typed twice; the
|
||||
/// enable button stays disabled until the two fields match. This is
|
||||
/// the only defence on the *first device* because there's nothing
|
||||
/// remote to validate against yet.
|
||||
/// 2. **Validation probe.** After the first device successfully enables
|
||||
/// sync, a small encrypted canary is uploaded under
|
||||
/// [SyncDocumentKind.syncValidationProbe]. On every *subsequent*
|
||||
/// device, the probe is decrypted with the candidate key before the
|
||||
/// key is persisted — wrong passwords are caught immediately rather
|
||||
/// than silently breaking the next restore.
|
||||
class SyncSetupCard extends HookConsumerWidget {
|
||||
const SyncSetupCard({super.key, required this.email});
|
||||
|
||||
final String? email;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final passwordController = useTextEditingController();
|
||||
final confirmController = useTextEditingController();
|
||||
useListenable(passwordController);
|
||||
useListenable(confirmController);
|
||||
|
||||
final busy = useState(false);
|
||||
final error = useState<String?>(null);
|
||||
|
||||
final password = passwordController.text;
|
||||
final confirm = confirmController.text;
|
||||
final passwordsMatch = password.isNotEmpty && password == confirm;
|
||||
|
||||
Future<void> setupSyncKey() async {
|
||||
if (password.isEmpty) {
|
||||
error.value = 'Please enter your password';
|
||||
return;
|
||||
}
|
||||
if (password != confirm) {
|
||||
error.value = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
final syncKey = _deriveSyncKey(email: email, password: password);
|
||||
|
||||
// Validate the candidate key against any existing encrypted envelope
|
||||
// before persisting. Without this, a wrong password is silently
|
||||
// accepted, leaving the in-memory syncKey unable to decrypt future
|
||||
// restores — and a "Set up sync" UX that looks successful is
|
||||
// actively misleading.
|
||||
final syncRepo = ref.read(accountSyncRepositoryProvider.notifier);
|
||||
final probe = await _findValidationProbe(syncRepo);
|
||||
if (probe != null) {
|
||||
final ok = await _canDecrypt(probe.contentBlob, syncKey);
|
||||
if (!ok) {
|
||||
error.value =
|
||||
'Password did not match your existing encrypted backups.';
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// First device on this account — nothing exists to validate
|
||||
// against, so we leave a probe behind for the *next* device's
|
||||
// setup to verify against. Failing the whole setup if the
|
||||
// probe upload fails (rather than silently degrading to "no
|
||||
// probe written") keeps the contract simple: if sync is
|
||||
// enabled here, a probe exists on the server. The user can
|
||||
// retry — probe upload is a single small insert and the most
|
||||
// likely failure cause is transient network.
|
||||
await _uploadValidationProbe(syncRepo, syncKey);
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(accountAuthRepositoryProvider.notifier)
|
||||
.setSyncKey(syncKey);
|
||||
} catch (e) {
|
||||
error.value = 'Failed to set up sync: $e';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.lock_outlined),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Set Up Encrypted Sync',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enter your account password to enable end-to-end encrypted '
|
||||
'sync. Your data is encrypted on-device before upload — '
|
||||
'the server never sees your settings.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: passwordController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Account Password',
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: error.value,
|
||||
),
|
||||
obscureText: true,
|
||||
enabled: !busy.value,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: confirmController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Confirm Password',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
obscureText: true,
|
||||
enabled: !busy.value,
|
||||
onSubmitted: (_) => setupSyncKey(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton(
|
||||
onPressed: (busy.value || !passwordsMatch)
|
||||
? null
|
||||
: setupSyncKey,
|
||||
child: busy.value
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Enable Sync'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Derives the sync key the rest of the app stores. The output is a
|
||||
/// 64-char hex string fed to [SecureData] as a passphrase; the real
|
||||
/// argon2id stretch happens inside [SecureData] every time it
|
||||
/// encrypts/decrypts.
|
||||
///
|
||||
/// This HMAC step is not the cryptographic primitive — it just
|
||||
/// canonicalises `(email, password)` into a fixed-length deterministic
|
||||
/// string. Two devices reaching the same [syncKey] is necessary for
|
||||
/// cross-device decrypt to work.
|
||||
String _deriveSyncKey({required String? email, required String password}) {
|
||||
final normalizedEmail = email?.toLowerCase() ?? '';
|
||||
final hmac = Hmac(sha256, utf8.encode('weblibre-sync'));
|
||||
final digest = hmac.convert(utf8.encode('$normalizedEmail:$password'));
|
||||
return digest.toString();
|
||||
}
|
||||
|
||||
/// Pick any existing remote envelope to verify the candidate sync key
|
||||
/// against.
|
||||
///
|
||||
/// Checks the dedicated [SyncDocumentKind.syncValidationProbe] first so
|
||||
/// every setup pays the same small-payload decrypt cost regardless of how
|
||||
/// large the user's settings snapshots are. Falls back to scanning the
|
||||
/// other kinds in declaration order if no probe is found — that covers
|
||||
/// users who set up sync before probes were a thing.
|
||||
Future<SyncDocumentResult?> _findValidationProbe(
|
||||
AccountSyncRepository syncRepo,
|
||||
) async {
|
||||
final ordered = [
|
||||
SyncDocumentKind.syncValidationProbe,
|
||||
...SyncDocumentKind.values.where(
|
||||
(k) => k != SyncDocumentKind.syncValidationProbe,
|
||||
),
|
||||
];
|
||||
for (final kind in ordered) {
|
||||
final docs = await syncRepo.listDocuments(kind: kind);
|
||||
if (docs.isEmpty) continue;
|
||||
final result = await syncRepo.fetchDocument(id: docs.first.id);
|
||||
if (result != null) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Encrypt a small canary payload under [syncKey] and upload it as a
|
||||
/// [SyncDocumentKind.syncValidationProbe] document. Future devices fetch
|
||||
/// this row in [_findValidationProbe] and refuse to persist a mismatching
|
||||
/// candidate key.
|
||||
///
|
||||
/// The probe payload is intentionally tiny and version-tagged so a future
|
||||
/// migration can recognise it without breaking older clients (they will
|
||||
/// simply still decrypt-and-succeed, since the marker is opaque to them).
|
||||
Future<void> _uploadValidationProbe(
|
||||
AccountSyncRepository syncRepo,
|
||||
String syncKey,
|
||||
) async {
|
||||
final payload = utf8.encode('weblibre:sync-probe:v1');
|
||||
final secureData = SecureData(
|
||||
argon2Params: Argon2Params.memoryConstrained(),
|
||||
);
|
||||
final ciphertext = await secureData.encrypt(payload, syncKey);
|
||||
await syncRepo.storeDocument(
|
||||
kind: SyncDocumentKind.syncValidationProbe,
|
||||
schemaVersion: 1,
|
||||
contentBlob: base64Encode(ciphertext),
|
||||
label: 'sync validation probe',
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _canDecrypt(String contentBlob, String syncKey) async {
|
||||
try {
|
||||
final encrypted = base64Decode(contentBlob);
|
||||
final secureData = SecureData(
|
||||
argon2Params: Argon2Params.memoryConstrained(),
|
||||
);
|
||||
await secureData.decrypt(encrypted, syncKey);
|
||||
return true;
|
||||
} catch (_) {
|
||||
// Wrong password, corrupted envelope, or unsupported version — in
|
||||
// all cases the candidate key is unusable for restoring this
|
||||
// backup, so decline to persist it. The user can reset the sync
|
||||
// key from the settings UI if their server-side data really is
|
||||
// corrupted.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -242,47 +242,53 @@ class _ScreenshotsSection extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 200,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: previews.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final p = previews[index];
|
||||
return GestureDetector(
|
||||
onTap: () => showDialog<void>(
|
||||
context: context,
|
||||
barrierColor: Colors.black87,
|
||||
builder: (context) => Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: EdgeInsets.zero,
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: InteractiveViewer(
|
||||
child: Hero(
|
||||
tag: p.imageUrl,
|
||||
child: Image.network(p.imageUrl, fit: BoxFit.contain),
|
||||
child: FadingScroll(
|
||||
fadingSize: 15,
|
||||
builder: (context, controller) {
|
||||
return ListView.separated(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: previews.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final p = previews[index];
|
||||
return GestureDetector(
|
||||
onTap: () => showDialog<void>(
|
||||
context: context,
|
||||
barrierColor: Colors.black87,
|
||||
builder: (context) => Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: EdgeInsets.zero,
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: InteractiveViewer(
|
||||
child: Hero(
|
||||
tag: p.imageUrl,
|
||||
child: Image.network(p.imageUrl, fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Hero(
|
||||
tag: p.imageUrl,
|
||||
child: Image.network(
|
||||
p.thumbnailUrl ?? p.imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
width: 300,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.broken_image_outlined),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Hero(
|
||||
tag: p.imageUrl,
|
||||
child: Image.network(
|
||||
p.thumbnailUrl ?? p.imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
width: 300,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
child: const Icon(Icons.broken_image_outlined),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -86,8 +86,6 @@ class AddonManagerScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
enum _AddonManagerMenuAction { checkForUpdates, installFromFile }
|
||||
|
||||
class _AddonManagerOverflowMenu extends ConsumerWidget {
|
||||
final bool canCheckForUpdates;
|
||||
|
||||
@@ -99,44 +97,39 @@ class _AddonManagerOverflowMenu extends ConsumerWidget {
|
||||
bulkAddonUpdateProvider.select((value) => value.isLoading),
|
||||
);
|
||||
|
||||
return PopupMenuButton<_AddonManagerMenuAction>(
|
||||
icon: updatesBusy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.more_vert),
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case _AddonManagerMenuAction.checkForUpdates:
|
||||
await ref.read(bulkAddonUpdateProvider.notifier).triggerAll();
|
||||
if (!context.mounted) return;
|
||||
showInfoMessage(
|
||||
context,
|
||||
'Background update checks started for installed extensions',
|
||||
);
|
||||
case _AddonManagerMenuAction.installFromFile:
|
||||
await showInstallLocalAddonDialog(context);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: _AddonManagerMenuAction.checkForUpdates,
|
||||
enabled: canCheckForUpdates && !updatesBusy,
|
||||
child: const ListTile(
|
||||
leading: Icon(Icons.system_update_alt),
|
||||
title: Text('Check for updates'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
return MenuAnchor(
|
||||
builder: (context, controller, _) => IconButton(
|
||||
icon: updatesBusy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.more_vert),
|
||||
onPressed: () =>
|
||||
controller.isOpen ? controller.close() : controller.open(),
|
||||
),
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(Icons.system_update_alt),
|
||||
onPressed: canCheckForUpdates && !updatesBusy
|
||||
? () async {
|
||||
await ref.read(bulkAddonUpdateProvider.notifier).triggerAll();
|
||||
if (!context.mounted) return;
|
||||
showInfoMessage(
|
||||
context,
|
||||
'Background update checks started for installed extensions',
|
||||
);
|
||||
}
|
||||
: null,
|
||||
child: const Text('Check for updates'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _AddonManagerMenuAction.installFromFile,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.file_open),
|
||||
title: Text('Install from file'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(Icons.file_open),
|
||||
onPressed: () async {
|
||||
await showInstallLocalAddonDialog(context);
|
||||
},
|
||||
child: const Text('Install from file'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -159,6 +159,11 @@ extension DefineFunctions on i6.CommonDatabase {
|
||||
required String Function(int, String?) lexoRankPrevious,
|
||||
required String Function(String?, String?) lexoRankReorderAfter,
|
||||
required String Function(String?, String?) lexoRankReorderBefore,
|
||||
required int Function() generateContentHash,
|
||||
required bool Function(String?) urlIndexable,
|
||||
required String Function(String?) urlCanonical,
|
||||
required String Function(String?) urlHost,
|
||||
required String Function(String?) urlPath,
|
||||
}) {
|
||||
createFunction(
|
||||
functionName: 'lexo_rank_next',
|
||||
@@ -196,5 +201,44 @@ extension DefineFunctions on i6.CommonDatabase {
|
||||
return lexoRankReorderBefore(arg0, arg1);
|
||||
},
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'generate_content_hash',
|
||||
argumentCount: const i6.AllowedArgumentCount(0),
|
||||
function: (args) {
|
||||
return generateContentHash();
|
||||
},
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'url_indexable',
|
||||
argumentCount: const i6.AllowedArgumentCount(1),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
return urlIndexable(arg0);
|
||||
},
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'url_canonical',
|
||||
argumentCount: const i6.AllowedArgumentCount(1),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
return urlCanonical(arg0);
|
||||
},
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'url_host',
|
||||
argumentCount: const i6.AllowedArgumentCount(1),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
return urlHost(arg0);
|
||||
},
|
||||
);
|
||||
createFunction(
|
||||
functionName: 'url_path',
|
||||
argumentCount: const i6.AllowedArgumentCount(1),
|
||||
function: (args) {
|
||||
final arg0 = args[0] as String?;
|
||||
return urlPath(arg0);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ enum BangGroup {
|
||||
'https://raw.githubusercontent.com/FaFre/bangs/main/data/kagi_bangs.json',
|
||||
bundled: 'assets/bangs/kagi_bangs.json',
|
||||
),
|
||||
user(remote: null, bundled: null);
|
||||
user(remote: null, bundled: null),
|
||||
weblibre(remote: null, bundled: null);
|
||||
|
||||
final String? bundled;
|
||||
final String? remote;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
|
||||
|
||||
const webSearchBangTrigger = 'wl';
|
||||
|
||||
final webSearchBang = BangData(
|
||||
websiteName: 'WebLibre Search',
|
||||
domain: 'weblibre.eu',
|
||||
trigger: webSearchBangTrigger,
|
||||
// Placeholder template — selecting this bang routes the query through the
|
||||
// token-based search backend instead of opening this URL.
|
||||
urlTemplate: 'https://weblibre.eu/?q={{{s}}}',
|
||||
group: BangGroup.weblibre,
|
||||
searxngApi: false,
|
||||
);
|
||||
|
||||
final webSearchBangKey = BangKey(
|
||||
group: BangGroup.weblibre,
|
||||
trigger: webSearchBangTrigger,
|
||||
);
|
||||
|
||||
bool isWebSearchBang(BangData? bang) =>
|
||||
bang != null && bang.group == BangGroup.weblibre;
|
||||
@@ -47,6 +47,12 @@ class BangSearch extends _$BangSearch {
|
||||
return bang.getTemplateUrl(searchQuery);
|
||||
}
|
||||
|
||||
Future<void> removeSearchEntry(String searchQuery) {
|
||||
return ref
|
||||
.read(bangDataRepositoryProvider.notifier)
|
||||
.removeSearchEntry(searchQuery);
|
||||
}
|
||||
|
||||
Future<void> search(String input) async {
|
||||
if (input.isNotEmpty) {
|
||||
await ref.read(bangDatabaseProvider).bangDao.queryBangs(input).get().then(
|
||||
|
||||
@@ -33,7 +33,7 @@ final class BangSearchProvider
|
||||
BangSearch create() => BangSearch();
|
||||
}
|
||||
|
||||
String _$bangSearchHash() => r'feed24edfe703b0697f4a855be9c7359c456b0f2';
|
||||
String _$bangSearchHash() => r'a541da1cfd8155abf03bbe19ab7c7200febd821c';
|
||||
|
||||
abstract class _$BangSearch extends $StreamNotifier<List<BangData>> {
|
||||
Stream<List<BangData>> build();
|
||||
|
||||
@@ -99,11 +99,17 @@ class BangDataRepository extends _$BangDataRepository {
|
||||
}
|
||||
|
||||
Stream<List<SearchHistoryEntry>> watchSearchHistory({required int limit}) {
|
||||
return ref
|
||||
final query = ref
|
||||
.read(bangDatabaseProvider)
|
||||
.definitionsDrift
|
||||
.searchHistoryEntries(limit: limit)
|
||||
.watch();
|
||||
.searchHistoryEntries(limit: limit);
|
||||
|
||||
return () async* {
|
||||
// Emit a direct read first so Recent Searches is populated immediately
|
||||
// on app start instead of waiting for the next bang_history write.
|
||||
yield await query.get();
|
||||
yield* query.watch();
|
||||
}();
|
||||
}
|
||||
|
||||
Future<void> increaseFrequency(BangKey key) {
|
||||
|
||||
@@ -42,7 +42,7 @@ final class BangDataRepositoryProvider
|
||||
}
|
||||
|
||||
String _$bangDataRepositoryHash() =>
|
||||
r'c562ef10d75ca6dee13805f84d2491cf2a89aaac';
|
||||
r'c8e2b17214117a8d88b5246bdcc565bc15e8e4ac';
|
||||
|
||||
abstract class _$BangDataRepository extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
@@ -21,6 +21,7 @@ import 'package:exceptions/exceptions.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/bangs/data/database/database.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/bang_group.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/web_search_bang.dart';
|
||||
import 'package:weblibre/features/bangs/data/providers.dart';
|
||||
import 'package:weblibre/features/bangs/data/services/data_source.dart';
|
||||
|
||||
@@ -170,6 +171,21 @@ class BangSyncRepository extends _$BangSyncRepository {
|
||||
.watchSingleOrNull();
|
||||
}
|
||||
|
||||
Future<Result<void>> _syncSyntheticBangs() async {
|
||||
try {
|
||||
await ref.read(bangDatabaseProvider).bangDao.upsertBang(webSearchBang);
|
||||
return Result.success(null);
|
||||
} catch (e) {
|
||||
return Result.failure(
|
||||
ErrorMessage(
|
||||
message: 'Failed to sync synthetic Bangs',
|
||||
source: 'BangSync',
|
||||
details: e,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<BangGroup, Result<void>>> syncBundledBangGroups({
|
||||
Set<BangGroup>? groups,
|
||||
}) async {
|
||||
@@ -183,7 +199,10 @@ class BangSyncRepository extends _$BangSyncRepository {
|
||||
).then((result) => MapEntry(source, result)),
|
||||
);
|
||||
|
||||
return Map.fromEntries(await Future.wait(futures));
|
||||
return {
|
||||
...Map.fromEntries(await Future.wait(futures)),
|
||||
BangGroup.weblibre: await _syncSyntheticBangs(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -42,7 +42,7 @@ final class BangSyncRepositoryProvider
|
||||
}
|
||||
|
||||
String _$bangSyncRepositoryHash() =>
|
||||
r'1347dcbd03f6a1a4fa3bbbae5d9302098d260c50';
|
||||
r'cdb78ef0243224fdb7b3a8660f0920c471987296';
|
||||
|
||||
abstract class _$BangSyncRepository extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
@@ -24,6 +24,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
|
||||
import 'package:weblibre/features/bangs/domain/providers/search.dart';
|
||||
import 'package:weblibre/features/bangs/presentation/widgets/bang_details.dart';
|
||||
import 'package:weblibre/features/user/domain/providers.dart';
|
||||
@@ -36,7 +37,7 @@ class BangSearchScreen extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final resultsAsync = ref.watch(bangSearchProvider);
|
||||
final resultsAsync = ref.watch(seamlessBangProvider);
|
||||
final incognitoEnabled = ref.watch(incognitoModeEnabledProvider);
|
||||
|
||||
final focusNode = useFocusNode();
|
||||
@@ -45,11 +46,9 @@ class BangSearchScreen extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
useOnListenableChange(textEditingController, () {
|
||||
unawaited(
|
||||
ref
|
||||
.read(bangSearchProvider.notifier)
|
||||
.search(textEditingController.text),
|
||||
);
|
||||
ref
|
||||
.read(seamlessBangProvider.notifier)
|
||||
.search(textEditingController.text);
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
@@ -79,24 +78,26 @@ class BangSearchScreen extends HookConsumerWidget {
|
||||
body: SafeArea(
|
||||
child: resultsAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (bangs) => FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView.builder(
|
||||
controller: controller,
|
||||
itemCount: bangs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final bang = bangs[index];
|
||||
return BangDetails(
|
||||
bang,
|
||||
onTap: () {
|
||||
context.pop(bang.toKey());
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
data: (bangs) {
|
||||
return FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView.builder(
|
||||
controller: controller,
|
||||
itemCount: bangs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final bang = bangs[index];
|
||||
return BangDetails(
|
||||
bang,
|
||||
onTap: () {
|
||||
context.pop(bang.toKey());
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (error, stackTrace) => Center(
|
||||
child: FailureWidget(title: 'Bang Search failed', exception: error),
|
||||
),
|
||||
|
||||
@@ -31,15 +31,18 @@ class BrowserIcon with FastEquatable {
|
||||
final Color? dominantColor;
|
||||
final IconSource source;
|
||||
|
||||
static Future<BrowserIcon> fromBytes(
|
||||
static Future<BrowserIcon?> fromBytes(
|
||||
Uint8List bytes, {
|
||||
required Color? dominantColor,
|
||||
required IconSource source,
|
||||
}) async {
|
||||
final image = await tryDecodeImage(bytes);
|
||||
if (image == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return BrowserIcon(
|
||||
image: image!,
|
||||
image: image,
|
||||
dominantColor: dominantColor,
|
||||
source: source,
|
||||
);
|
||||
|
||||
@@ -48,23 +48,6 @@ part 'tab_state.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class TabStates extends _$TabStates {
|
||||
/// Disposes images from a TabState to free GPU memory.
|
||||
void _disposeTabImages(TabState tab) {
|
||||
tab.icon?.dispose();
|
||||
tab.thumbnail?.dispose();
|
||||
}
|
||||
|
||||
/// Updates state while disposing images from removed tabs.
|
||||
void _updateState(Map<String, TabState> newState) {
|
||||
// Find and dispose images from tabs that are being removed
|
||||
for (final tabId in state.keys) {
|
||||
if (!newState.containsKey(tabId)) {
|
||||
_disposeTabImages(state[tabId]!);
|
||||
}
|
||||
}
|
||||
|
||||
state = newState;
|
||||
}
|
||||
|
||||
Future<void> _onTabContentStateChange(TabContentState contentState) async {
|
||||
final current = await patchedState(contentState.id);
|
||||
@@ -103,7 +86,7 @@ class TabStates extends _$TabStates {
|
||||
showToolbarAsExpanded: contentState.showToolbarAsExpanded,
|
||||
);
|
||||
|
||||
_updateState({...state}..[contentState.id] = newState);
|
||||
state = {...state}..[contentState.id] = newState;
|
||||
|
||||
if (newState.isFinishedLoading) {
|
||||
ref
|
||||
@@ -139,9 +122,6 @@ class TabStates extends _$TabStates {
|
||||
final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes));
|
||||
final current = state[tabId] ?? TabState.$default(tabId);
|
||||
|
||||
// Dispose old icon only after successfully creating new one
|
||||
current.icon?.dispose();
|
||||
|
||||
state = {...state}..[tabId] = current.copyWith.icon(image);
|
||||
}
|
||||
|
||||
@@ -151,9 +131,6 @@ class TabStates extends _$TabStates {
|
||||
final image = await bytes.mapNotNull((bytes) => tryDecodeImage(bytes));
|
||||
final current = state[tabId] ?? TabState.$default(tabId);
|
||||
|
||||
// Dispose old thumbnail only after successfully creating new one
|
||||
current.thumbnail?.dispose();
|
||||
|
||||
state = {...state}..[tabId] = current.copyWith.thumbnail(image);
|
||||
}
|
||||
|
||||
@@ -368,12 +345,6 @@ class TabStates extends _$TabStates {
|
||||
);
|
||||
|
||||
ref.onDispose(() async {
|
||||
// Dispose all remaining tab images
|
||||
for (final tab in state.values) {
|
||||
_disposeTabImages(tab);
|
||||
}
|
||||
|
||||
// Cancel all stream subscriptions
|
||||
for (final sub in subscriptions) {
|
||||
await sub.cancel();
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ final class TabStatesProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$tabStatesHash() => r'6fc9a91b1343f9ae0b442100bed20e42448afa37';
|
||||
String _$tabStatesHash() => r'd7efef406dd132a3ef1a67da06c794d18ccb484a';
|
||||
|
||||
abstract class _$TabStates extends $Notifier<Map<String, TabState>> {
|
||||
Map<String, TabState> build();
|
||||
|
||||
@@ -35,8 +35,7 @@ part 'web_extensions_state.g.dart';
|
||||
class WebExtensionsState extends _$WebExtensionsState {
|
||||
late final LRUCache<String, EquatableImage> _imageCache;
|
||||
|
||||
WebExtensionsState()
|
||||
: _imageCache = LRUCache(50, onEvict: (image) => image.dispose());
|
||||
WebExtensionsState() : _imageCache = LRUCache(50);
|
||||
|
||||
void _onExtensionUpdate(ExtensionDataEvent event) {
|
||||
if (!ref.mounted) {
|
||||
@@ -46,11 +45,6 @@ class WebExtensionsState extends _$WebExtensionsState {
|
||||
final ExtensionDataEvent(:extensionId, :data) = event;
|
||||
|
||||
if (data != null) {
|
||||
final cachedIcon = _imageCache.get(extensionId);
|
||||
if (cachedIcon != null && cachedIcon.value == null) {
|
||||
_imageCache.remove(extensionId);
|
||||
}
|
||||
|
||||
final current =
|
||||
state[extensionId] ??
|
||||
WebExtensionState(
|
||||
@@ -74,7 +68,6 @@ class WebExtensionsState extends _$WebExtensionsState {
|
||||
} else {
|
||||
if (state.containsKey(extensionId)) {
|
||||
state = {...state}..remove(extensionId);
|
||||
// remove() triggers onEvict which handles disposal
|
||||
_imageCache.remove(extensionId);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +84,6 @@ class WebExtensionsState extends _$WebExtensionsState {
|
||||
}
|
||||
|
||||
if (image != null) {
|
||||
// set() will evict the old entry via onEvict callback, which handles disposal
|
||||
_imageCache.set(extensionId, image);
|
||||
|
||||
if (state.containsKey(extensionId)) {
|
||||
@@ -198,7 +190,6 @@ class WebExtensionsState extends _$WebExtensionsState {
|
||||
// Dispose all cached images
|
||||
_imageCache.clear();
|
||||
|
||||
// Cancel all stream subscriptions
|
||||
for (final sub in subscriptions) {
|
||||
unawaited(sub.cancel());
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ final class WebExtensionsStateProvider
|
||||
}
|
||||
|
||||
String _$webExtensionsStateHash() =>
|
||||
r'e067323e9a0a466e46e0c4d529667950ee62d4ca';
|
||||
r'59379bbaa9ae867f9dc50dae5c0f06b159faaedf';
|
||||
|
||||
final class WebExtensionsStateFamily extends $Family
|
||||
with
|
||||
|
||||
@@ -26,6 +26,7 @@ import 'package:nullability/nullability.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||
@@ -49,6 +50,20 @@ import 'package:weblibre/utils/debouncer.dart';
|
||||
|
||||
part 'tab.g.dart';
|
||||
|
||||
sealed class TabBackPromptBehavior {
|
||||
const TabBackPromptBehavior();
|
||||
}
|
||||
|
||||
final class BackgroundAppTabBackPromptBehavior extends TabBackPromptBehavior {
|
||||
const BackgroundAppTabBackPromptBehavior();
|
||||
}
|
||||
|
||||
final class ReturnToSearchTabBackPromptBehavior extends TabBackPromptBehavior {
|
||||
final TabType tabType;
|
||||
|
||||
const ReturnToSearchTabBackPromptBehavior({required this.tabType});
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class TabRepository extends _$TabRepository {
|
||||
final _tabsService = GeckoTabService();
|
||||
@@ -57,20 +72,20 @@ class TabRepository extends _$TabRepository {
|
||||
bool _reclosing = false;
|
||||
bool _suppressNextReclose = false;
|
||||
|
||||
final _tabFromIntent = <String>{};
|
||||
final _tabBackPromptBehavior = <String, TabBackPromptBehavior>{};
|
||||
final _closeLock = Lock();
|
||||
final _pendingIsolationCleanup = <String>{};
|
||||
|
||||
bool hasLaunchedFromIntent(String? tabId) {
|
||||
TabBackPromptBehavior? backPromptBehaviorFor(String? tabId) {
|
||||
if (tabId == null) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
return _tabFromIntent.contains(tabId);
|
||||
return _tabBackPromptBehavior[tabId];
|
||||
}
|
||||
|
||||
void clearLaunchedFromIntent(String tabId) {
|
||||
_tabFromIntent.remove(tabId);
|
||||
void clearBackPromptBehavior(String tabId) {
|
||||
_tabBackPromptBehavior.remove(tabId);
|
||||
}
|
||||
|
||||
Future<String?> _resolveParentIdForContext({
|
||||
@@ -105,6 +120,7 @@ class TabRepository extends _$TabRepository {
|
||||
TabContainerSelection containerSelection =
|
||||
const TabContainerSelection.useSelected(),
|
||||
bool launchedFromIntent = false,
|
||||
TabBackPromptBehavior? promptOnBackBehavior,
|
||||
}) async {
|
||||
final tabDao = ref.read(tabDatabaseProvider).tabDao;
|
||||
|
||||
@@ -152,12 +168,43 @@ class TabRepository extends _$TabRepository {
|
||||
);
|
||||
|
||||
if (launchedFromIntent) {
|
||||
_tabFromIntent.add(newTabId);
|
||||
_tabBackPromptBehavior[newTabId] =
|
||||
promptOnBackBehavior ?? const BackgroundAppTabBackPromptBehavior();
|
||||
} else if (promptOnBackBehavior != null) {
|
||||
_tabBackPromptBehavior[newTabId] = promptOnBackBehavior;
|
||||
}
|
||||
|
||||
return newTabId;
|
||||
}
|
||||
|
||||
Future<bool> _closeRestoredPrivateCaptureTabs(List<String> tabIds) async {
|
||||
if (tabIds.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final captureRows = await ref
|
||||
.read(tabDatabaseProvider)
|
||||
.captureTabDao
|
||||
.findAll();
|
||||
final tabStates = ref.read(tabStatesProvider);
|
||||
final restoredPrivateCaptureTabIds = captureRows
|
||||
.where((row) => row.createdAt.isBefore(_sessionStartedAt))
|
||||
.map((row) => row.tabId)
|
||||
.where((tabId) => tabIds.contains(tabId))
|
||||
.where((tabId) => tabStates[tabId]?.tabMode is PrivateTabMode)
|
||||
.toList(growable: false);
|
||||
|
||||
if (restoredPrivateCaptureTabIds.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await _closeTabsInternal(
|
||||
restoredPrivateCaptureTabIds,
|
||||
recordTombstones: false,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<List<String>> addMultipleTabs({
|
||||
required List<AddTabParams> tabs,
|
||||
String? selectTabId,
|
||||
@@ -571,6 +618,7 @@ class TabRepository extends _$TabRepository {
|
||||
}
|
||||
|
||||
for (final tabId in tabIds) {
|
||||
_tabBackPromptBehavior.remove(tabId);
|
||||
final isolationContextId = ref
|
||||
.read(tabStatesProvider)[tabId]
|
||||
?.isolationContextId;
|
||||
@@ -935,6 +983,8 @@ class TabRepository extends _$TabRepository {
|
||||
await _clearTombstonesForCurrentTabs(next.value);
|
||||
} else if (await _recloseRestoredClosedTabs(next.value)) {
|
||||
return;
|
||||
} else if (await _closeRestoredPrivateCaptureTabs(next.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Only sync tabs if there has been a previous value or is not empty
|
||||
|
||||
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$tabRepositoryHash() => r'f458baaf9061143a668470dccd57f32664728951';
|
||||
String _$tabRepositoryHash() => r'ea5caa3c9c4b19898db9de1f77d5476794b8b66f';
|
||||
|
||||
abstract class _$TabRepository extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
+13
-6
@@ -17,6 +17,7 @@
|
||||
* 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:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
@@ -71,12 +72,18 @@ class _SelectBookmarkFolderSheet extends HookConsumerWidget {
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.5,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: FolderTreePicker(
|
||||
selectedFolderGuid: selectedGuid,
|
||||
excludeFolderGuids: excludeFolderGuids,
|
||||
entryGuid: BookmarkRoot.root.id,
|
||||
),
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return SingleChildScrollView(
|
||||
controller: controller,
|
||||
child: FolderTreePicker(
|
||||
selectedFolderGuid: selectedGuid,
|
||||
excludeFolderGuids: excludeFolderGuids,
|
||||
entryGuid: BookmarkRoot.root.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -45,6 +45,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/ge
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
|
||||
part 'providers.g.dart';
|
||||
|
||||
@@ -754,19 +755,25 @@ EquatableValue<List<TabPreview>> filteredTabPreviews(
|
||||
return EquatableValue([]);
|
||||
}
|
||||
|
||||
final sandboxCaptureMap =
|
||||
ref.watch(sandboxCaptureMapProvider).value ?? const {};
|
||||
|
||||
return EquatableValue(
|
||||
tabSearchResults.results
|
||||
.where((tab) => availableTabStates.value.containsKey(tab.id))
|
||||
.map((tab) {
|
||||
final tabState = availableTabStates.value[tab.id]!;
|
||||
final sandboxSourceUri = parseSandboxSource(
|
||||
sandboxCaptureMap[tab.id],
|
||||
);
|
||||
|
||||
return TabPreview(
|
||||
id: tab.id,
|
||||
containerId: tab.containerId,
|
||||
title: tab.title ?? tabState.title,
|
||||
icon: tabState.icon,
|
||||
url: tab.cleanUrl ?? tabState.url,
|
||||
highlightedUrl: tab.url,
|
||||
url: sandboxSourceUri ?? tab.cleanUrl ?? tabState.url,
|
||||
highlightedUrl: sandboxSourceUri?.toString() ?? tab.url,
|
||||
extractedContent: tab.extractedContent,
|
||||
fullContent: tab.fullContent,
|
||||
sourceSearchQuery: tabSearchResults.query,
|
||||
|
||||
@@ -1066,7 +1066,7 @@ final class FilteredTabPreviewsProvider
|
||||
}
|
||||
|
||||
String _$filteredTabPreviewsHash() =>
|
||||
r'2327ad86650b3baa6b9339e280abfc7463c72cf9';
|
||||
r'074df0d2000ae325fbd1db775abe4836491b32dd';
|
||||
|
||||
final class FilteredTabPreviewsFamily extends $Family
|
||||
with
|
||||
|
||||
+2
@@ -19,6 +19,7 @@
|
||||
*/
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
|
||||
part 'browser_data.g.dart';
|
||||
@@ -46,6 +47,7 @@ class BrowserDataService extends _$BrowserDataService {
|
||||
await _service.deleteTabs();
|
||||
case DeleteBrowsingDataType.history:
|
||||
await _service.deleteBrowsingHistory();
|
||||
await ref.read(tabDatabaseProvider).historyDao.clear();
|
||||
case DeleteBrowsingDataType.cookies:
|
||||
await _service.deleteCookiesAndSiteData();
|
||||
case DeleteBrowsingDataType.cache:
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ final class BrowserDataServiceProvider
|
||||
}
|
||||
|
||||
String _$browserDataServiceHash() =>
|
||||
r'861943be10c4dea325484b6a28ba21f3ff0d29fa';
|
||||
r'b6586d14ef13ab2905072e1e26cabc17846eaf73';
|
||||
|
||||
abstract class _$BrowserDataService extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
+5
-6
@@ -52,6 +52,7 @@ import 'package:weblibre/features/user/data/models/engine_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/utils/exit_app.dart';
|
||||
import 'package:weblibre/utils/move_to_background.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
@@ -327,14 +328,12 @@ final List<ToolbarButtonDefinition> toolbarButtonRegistry = [
|
||||
: () async {
|
||||
final tabState = scope.tabState;
|
||||
if (tabState != null) {
|
||||
final searchText = tabState.url.scheme == 'about'
|
||||
? SearchRoute.emptySearchText
|
||||
: tabState.url.toString();
|
||||
final sandboxSourceUri = ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
);
|
||||
await SearchRoute(
|
||||
tabId: tabState.id,
|
||||
searchText: searchText.isEmpty
|
||||
? SearchRoute.emptySearchText
|
||||
: searchText,
|
||||
searchText: searchTextForTab(tabState, sandboxSourceUri),
|
||||
tabType: tabState.tabMode.toTabType(),
|
||||
).push(context);
|
||||
} else {
|
||||
|
||||
+10
-3
@@ -36,6 +36,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'
|
||||
as tab_data;
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
|
||||
@@ -356,6 +357,12 @@ Future<void> _cloneTabAsMode(
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId));
|
||||
if (tabState == null) return;
|
||||
|
||||
// Sandbox-captured tab: clone the canonical source URL so the new tab
|
||||
// either re-captures or loads the real site — never the loopback loader.
|
||||
final cloneUrl =
|
||||
ref.read(sandboxSourceUriForTabProvider(tabId: tabState.id)) ??
|
||||
tabState.url;
|
||||
|
||||
final containerData = await ref
|
||||
.read(tab_data.tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerData(selectedTabId);
|
||||
@@ -371,7 +378,7 @@ Future<void> _cloneTabAsMode(
|
||||
)
|
||||
: await repo.addTab(
|
||||
tabMode: TabMode.regular,
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(containerData),
|
||||
@@ -386,7 +393,7 @@ Future<void> _cloneTabAsMode(
|
||||
)
|
||||
: await repo.addTab(
|
||||
tabMode: TabMode.private,
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(containerData),
|
||||
@@ -394,7 +401,7 @@ Future<void> _cloneTabAsMode(
|
||||
),
|
||||
IsolatedTabMode() => await repo.addTab(
|
||||
tabMode: TabMode.newIsolated(),
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(containerData),
|
||||
|
||||
+2
-4
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shows a dialog asking the user whether to keep a tab that was opened from another app.
|
||||
/// Shows a dialog asking the user whether to keep a temporary tab.
|
||||
///
|
||||
/// Returns true if the user wants to keep the tab, false if they want to discard it.
|
||||
Future<bool?> showKeepTabDialog(BuildContext context) {
|
||||
@@ -27,9 +27,7 @@ Future<bool?> showKeepTabDialog(BuildContext context) {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Keep tab?'),
|
||||
content: const Text(
|
||||
'This tab was opened from another app. Do you want to keep it or discard it?',
|
||||
),
|
||||
content: const Text('Do you want to keep this tab or discard it?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
|
||||
+12
-5
@@ -18,6 +18,7 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
@@ -58,11 +59,17 @@ class _SelectFolderSheet extends HookConsumerWidget {
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.5,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: FolderTreePicker(
|
||||
selectedFolderGuid: selectedFolderGuid,
|
||||
entryGuid: BookmarkRoot.root.id,
|
||||
),
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return SingleChildScrollView(
|
||||
controller: controller,
|
||||
child: FolderTreePicker(
|
||||
selectedFolderGuid: selectedFolderGuid,
|
||||
entryGuid: BookmarkRoot.root.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
+12
@@ -24,6 +24,7 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/repositories/site_permissions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/tracking_protection_provider.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
|
||||
part 'site_settings_badge_provider.g.dart';
|
||||
|
||||
@@ -49,6 +50,17 @@ Future<SiteSettingsBadgeState> showSiteSettingsBadge(Ref ref) async {
|
||||
return SiteSettingsBadgeState.hidden;
|
||||
}
|
||||
|
||||
// Sandbox-captured tabs render content from a loopback server, so
|
||||
// permissions/tracking-exception lookups would key on the loader origin
|
||||
// rather than the canonical site. Suppress the badge entirely — the user
|
||||
// can't meaningfully change permissions for a sandboxed page anyway.
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
);
|
||||
if (sandboxSourceUri != null) {
|
||||
return SiteSettingsBadgeState.hidden;
|
||||
}
|
||||
|
||||
// Check tracking protection exception
|
||||
final hasTrackingException = await ref.watch(
|
||||
hasTrackingProtectionExceptionProvider(tabState.id).future,
|
||||
|
||||
+1
-1
@@ -53,4 +53,4 @@ final class ShowSiteSettingsBadgeProvider
|
||||
}
|
||||
|
||||
String _$showSiteSettingsBadgeHash() =>
|
||||
r'fbfe409cdd6656d5cd26e377a68acde6ff4294fa';
|
||||
r'6345920e4391786e47a89964c7ffa75818732584';
|
||||
|
||||
+39
-17
@@ -57,6 +57,7 @@ import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_
|
||||
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
|
||||
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart';
|
||||
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_autofocus.dart';
|
||||
import 'package:weblibre/features/small_web/presentation/controllers/small_web_mode_controller.dart';
|
||||
import 'package:weblibre/features/small_web/presentation/widgets/small_web_browser_overlay.dart';
|
||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||
@@ -1022,7 +1023,12 @@ class _Browser extends HookConsumerWidget {
|
||||
return OverlayPortal(
|
||||
controller: overlayController,
|
||||
overlayChildBuilder: (context) {
|
||||
return overlayBuilder!.call(context);
|
||||
// The OverlayPortal lifecycle is driven by ref.listen above, but
|
||||
// OverlayPortal can call this builder one extra frame after
|
||||
// dismiss() set the provider back to null (esp. on rebuilds
|
||||
// triggered by unrelated state changes). Render an empty box
|
||||
// instead of force-unwrapping a null builder.
|
||||
return overlayBuilder?.call(context) ?? const SizedBox.shrink();
|
||||
},
|
||||
child: Listener(
|
||||
onPointerDown: sheetDisplayed
|
||||
@@ -1035,6 +1041,9 @@ class _Browser extends HookConsumerWidget {
|
||||
child: BackButtonListener(
|
||||
onBackButtonPressed: () async {
|
||||
final tabState = ref.read(selectedTabStateProvider);
|
||||
final promptOnBackBehavior = ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.backPromptBehaviorFor(tabState?.id);
|
||||
|
||||
final tabCount = ref.read(
|
||||
tabListProvider.select((tabs) => tabs.value.length),
|
||||
@@ -1103,27 +1112,22 @@ class _Browser extends HookConsumerWidget {
|
||||
return true;
|
||||
}
|
||||
|
||||
//Go router has routes to go back to
|
||||
if (context.canPop()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.hasLaunchedFromIntent(tabState?.id)) {
|
||||
if (promptOnBackBehavior != null) {
|
||||
if (!context.mounted) return false;
|
||||
|
||||
final keep = await showKeepTabDialog(context);
|
||||
if (keep == true) {
|
||||
ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.clearLaunchedFromIntent(tabState!.id);
|
||||
|
||||
await moveToBackground();
|
||||
if (keep == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tabState != null) {
|
||||
if (keep) {
|
||||
if (tabState == null) {
|
||||
return true;
|
||||
}
|
||||
ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.clearBackPromptBehavior(tabState.id);
|
||||
} else if (tabState != null) {
|
||||
if (!await confirmIsolatedTabCloseIfNeeded(tabState.id)) {
|
||||
return true;
|
||||
}
|
||||
@@ -1133,7 +1137,25 @@ class _Browser extends HookConsumerWidget {
|
||||
.closeTab(tabState.id);
|
||||
}
|
||||
|
||||
await moveToBackground();
|
||||
if (!context.mounted) return true;
|
||||
|
||||
switch (promptOnBackBehavior) {
|
||||
case BackgroundAppTabBackPromptBehavior():
|
||||
await moveToBackground();
|
||||
break;
|
||||
case ReturnToSearchTabBackPromptBehavior(:final tabType):
|
||||
ref
|
||||
.read(searchAutofocusSuppressionProvider.notifier)
|
||||
.suppressNext();
|
||||
await SearchRoute(tabType: tabType).push(context);
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
//Go router has routes to go back to
|
||||
if (context.canPop()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+4
-14
@@ -248,6 +248,7 @@ class _ContainerHeader extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final containerColor = container.color;
|
||||
final containerPalette = ContainerColors.palette(context, containerColor);
|
||||
|
||||
return Container(
|
||||
width: 112,
|
||||
@@ -259,22 +260,11 @@ class _ContainerHeader extends StatelessWidget {
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color.alphaBlend(
|
||||
ContainerColors.forChip(containerColor),
|
||||
colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
Color.alphaBlend(
|
||||
containerColor.withValues(alpha: 0.12),
|
||||
colorScheme.surfaceContainer,
|
||||
),
|
||||
containerPalette.surfaceHighColor,
|
||||
containerPalette.surfaceColor,
|
||||
],
|
||||
),
|
||||
border: Border.all(
|
||||
color: Color.alphaBlend(
|
||||
containerColor.withValues(alpha: 0.25),
|
||||
colorScheme.outlineVariant.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
border: Border.all(color: containerPalette.outlineColor),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.08),
|
||||
|
||||
+87
-46
@@ -22,6 +22,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -73,6 +74,7 @@ import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
|
||||
import 'package:weblibre/features/user/domain/providers.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/controllers/website_title.dart';
|
||||
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
||||
@@ -122,47 +124,55 @@ class _BrowserMenuSheet extends HookConsumerWidget {
|
||||
|
||||
// Scrollable content
|
||||
Expanded(
|
||||
child: ListView(
|
||||
child: FadingScroll(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
children: [
|
||||
// Quick toggles (Desktop Mode / Reader Mode)
|
||||
if (selectedTabId != null) ...[
|
||||
_QuickTogglesGrid(selectedTabId: selectedTabId),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
children: [
|
||||
// Quick toggles (Desktop Mode / Reader Mode)
|
||||
if (selectedTabId != null) ...[
|
||||
_QuickTogglesGrid(selectedTabId: selectedTabId),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Page actions
|
||||
if (selectedTabId != null) ...[
|
||||
_PageActionsCard(selectedTabId: selectedTabId),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
// Page actions
|
||||
if (selectedTabId != null) ...[
|
||||
_PageActionsCard(selectedTabId: selectedTabId),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Extensions
|
||||
_ExtensionsCard(),
|
||||
const SizedBox(height: 16),
|
||||
// Extensions
|
||||
_ExtensionsCard(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Tab actions
|
||||
if (selectedTabId != null) ...[
|
||||
_TabActionsCard(selectedTabId: selectedTabId),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
// Tab actions
|
||||
if (selectedTabId != null) ...[
|
||||
_TabActionsCard(selectedTabId: selectedTabId),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Quick links grid
|
||||
_QuickLinksGrid(showContainerUi: settings.showContainerUi),
|
||||
const SizedBox(height: 16),
|
||||
// Quick links grid
|
||||
_QuickLinksGrid(
|
||||
showContainerUi: settings.showContainerUi,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Profile
|
||||
_ProfileCard(),
|
||||
const SizedBox(height: 16),
|
||||
// Profile
|
||||
_ProfileCard(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// App
|
||||
const _SettingsCard(),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
// App
|
||||
const _SettingsCard(),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -513,12 +523,15 @@ class _PageActionsCard extends HookConsumerWidget {
|
||||
title: const Text('Add Bookmark'),
|
||||
onTap: () async {
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||
final bookmarkUrl =
|
||||
ref.read(sandboxSourceUriForTabProvider(tabId: tabState.id)) ??
|
||||
tabState.url;
|
||||
Navigator.pop(context);
|
||||
await BookmarkEntryAddRoute(
|
||||
bookmarkInfo: jsonEncode(
|
||||
BookmarkInfo(
|
||||
title: tabState.titleOrAuthority,
|
||||
url: tabState.url.toString(),
|
||||
url: bookmarkUrl.toString(),
|
||||
).encode(),
|
||||
),
|
||||
).push(context);
|
||||
@@ -737,7 +750,10 @@ class _PinTopSiteTile extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
||||
final url = tabState?.url;
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: selectedTabId),
|
||||
);
|
||||
final url = sandboxSourceUri ?? tabState?.url;
|
||||
|
||||
final isPinned = useCachedFuture(
|
||||
() => url != null
|
||||
@@ -978,6 +994,11 @@ class _CloneTabExpansion extends ConsumerWidget {
|
||||
icon: MdiIcons.tab,
|
||||
onTap: () async {
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||
final cloneUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
) ??
|
||||
tabState.url;
|
||||
final containerData = await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerData(selectedTabId);
|
||||
@@ -987,7 +1008,7 @@ class _CloneTabExpansion extends ConsumerWidget {
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
tabMode: TabMode.regular,
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(containerData),
|
||||
@@ -1017,6 +1038,11 @@ class _CloneTabExpansion extends ConsumerWidget {
|
||||
iconColor: appColors.privateTabPurple,
|
||||
onTap: () async {
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||
final cloneUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
) ??
|
||||
tabState.url;
|
||||
final containerData = await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerData(selectedTabId);
|
||||
@@ -1025,7 +1051,7 @@ class _CloneTabExpansion extends ConsumerWidget {
|
||||
? await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
tabMode: TabMode.private,
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
@@ -1057,6 +1083,11 @@ class _CloneTabExpansion extends ConsumerWidget {
|
||||
iconColor: appColors.isolatedTabTeal,
|
||||
onTap: () async {
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||
final cloneUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
) ??
|
||||
tabState.url;
|
||||
final containerData = await ref
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerData(selectedTabId);
|
||||
@@ -1064,7 +1095,7 @@ class _CloneTabExpansion extends ConsumerWidget {
|
||||
final tabId = await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
tabMode: TabMode.newIsolated(),
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
@@ -1289,7 +1320,12 @@ class _ShareExpansion extends HookConsumerWidget {
|
||||
final settings = ref.watch(generalSettingsWithDefaultsProvider);
|
||||
final catalogAsync = ref.watch(urlCleanerCatalogServiceProvider);
|
||||
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
||||
final tabUrl = tabState?.url;
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: selectedTabId),
|
||||
);
|
||||
// Sandbox-captured tabs: every share/copy/QR/cleaner action must operate
|
||||
// on the canonical source URL — never the loopback loader.
|
||||
final tabUrl = sandboxSourceUri ?? tabState?.url;
|
||||
|
||||
final cleanedUrl = useState<Uri?>(null);
|
||||
final cleaner = useUrlCleanerController(
|
||||
@@ -1526,16 +1562,23 @@ class _SendToDeviceExpansion extends ConsumerWidget {
|
||||
);
|
||||
if (tabState == null) return;
|
||||
|
||||
final sendUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(
|
||||
tabId: tabState.id,
|
||||
),
|
||||
) ??
|
||||
tabState.url;
|
||||
final title = tabState.title.isNotEmpty
|
||||
? tabState.title
|
||||
: tabState.url.toString();
|
||||
: sendUrl.toString();
|
||||
|
||||
final success = await ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.sendTabToDevice(
|
||||
deviceId: device.deviceId,
|
||||
title: title,
|
||||
url: tabState.url.toString(),
|
||||
url: sendUrl.toString(),
|
||||
private: tabState.tabMode == TabMode.private,
|
||||
);
|
||||
|
||||
@@ -1884,7 +1927,7 @@ class _QuickLinksGrid extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final torConnected = ref.watch(
|
||||
final isTorActive = ref.watch(
|
||||
torProxyServiceProvider.select((value) => value.value?.isRunning == true),
|
||||
);
|
||||
|
||||
@@ -1955,7 +1998,7 @@ class _QuickLinksGrid extends ConsumerWidget {
|
||||
Navigator.pop(context);
|
||||
await const TorProxyRoute().push(context);
|
||||
},
|
||||
badge: torConnected,
|
||||
badge: isTorActive,
|
||||
badgeColor: AppColors.of(context).torActiveGreen,
|
||||
),
|
||||
),
|
||||
@@ -1980,8 +2023,6 @@ class _QuickLinksGrid extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: SizedBox.shrink()),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
+64
-42
@@ -34,7 +34,9 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/provid
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/toolbar_button.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||
|
||||
class CompactAppBarTitle extends ConsumerWidget {
|
||||
@@ -64,6 +66,10 @@ class CompactAppBarTitle extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
);
|
||||
|
||||
return CompactAppBarTitleView(
|
||||
tabState: tabState,
|
||||
isTabTunneled:
|
||||
@@ -71,6 +77,7 @@ class CompactAppBarTitle extends ConsumerWidget {
|
||||
siteSettingsBadgeState: siteSettingsBadgeState,
|
||||
longPressUrlCopy: settings.tabBarLongPressUrlCopy,
|
||||
containerColor: containerColor,
|
||||
sandboxSourceUri: sandboxSourceUri,
|
||||
onSiteSettingsTap: () {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider.notifier)
|
||||
@@ -79,7 +86,7 @@ class CompactAppBarTitle extends ConsumerWidget {
|
||||
onTitleTap: () async {
|
||||
await SearchRoute(
|
||||
tabId: tabState.id,
|
||||
searchText: _searchTextForTab(tabState),
|
||||
searchText: searchTextForTab(tabState, sandboxSourceUri),
|
||||
tabType: tabState.tabMode.toTabType(),
|
||||
).push(context);
|
||||
},
|
||||
@@ -98,6 +105,7 @@ class CompactAppBarTitleView extends StatelessWidget {
|
||||
this.tabIcon,
|
||||
this.longPressUrlCopy = true,
|
||||
this.containerColor,
|
||||
this.sandboxSourceUri,
|
||||
});
|
||||
|
||||
final TabState tabState;
|
||||
@@ -108,12 +116,16 @@ class CompactAppBarTitleView extends StatelessWidget {
|
||||
final Widget? tabIcon;
|
||||
final bool longPressUrlCopy;
|
||||
final Color? containerColor;
|
||||
final Uri? sandboxSourceUri;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final appColors = AppColors.of(context);
|
||||
final containerColor = this.containerColor;
|
||||
final containerPalette = containerColor != null
|
||||
? ContainerColors.palette(context, containerColor)
|
||||
: null;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
@@ -154,17 +166,11 @@ class CompactAppBarTitleView extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: containerColor != null
|
||||
? Color.alphaBlend(
|
||||
containerColor.withValues(alpha: 0.08),
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
)
|
||||
? containerPalette!.surfaceColor
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: containerColor != null
|
||||
? Border.all(
|
||||
color: containerColor.withValues(alpha: 0.5),
|
||||
width: 2,
|
||||
)
|
||||
border: containerPalette != null
|
||||
? Border.all(color: containerPalette.outlineColor)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
@@ -190,15 +196,23 @@ class CompactAppBarTitleView extends StatelessWidget {
|
||||
const Icon(MdiIcons.tunnelOutline, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
_SecurityStatusIcon(
|
||||
tabState: tabState,
|
||||
size: 16,
|
||||
containerColor: containerColor,
|
||||
),
|
||||
if (sandboxSourceUri != null) ...[
|
||||
Icon(
|
||||
MdiIcons.archiveLockOutline,
|
||||
color: theme.colorScheme.tertiary,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
] else
|
||||
_SecurityStatusIcon(
|
||||
tabState: tabState,
|
||||
size: 16,
|
||||
containerColor: containerPalette?.accentColor,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: UriBreadcrumb(
|
||||
uri: tabState.url,
|
||||
uri: sandboxSourceUri ?? tabState.url,
|
||||
showHttpScheme: false,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
@@ -206,7 +220,10 @@ class CompactAppBarTitleView extends StatelessWidget {
|
||||
onTooltipTriggered: longPressUrlCopy
|
||||
? () async {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: tabState.url.toString()),
|
||||
ClipboardData(
|
||||
text: (sandboxSourceUri ?? tabState.url)
|
||||
.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
@@ -250,6 +267,10 @@ class AppBarTitle extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
);
|
||||
|
||||
return AppBarTitleView(
|
||||
tabState: tabState,
|
||||
isTabTunneled:
|
||||
@@ -257,6 +278,7 @@ class AppBarTitle extends ConsumerWidget {
|
||||
siteSettingsBadgeState: siteSettingsBadgeState,
|
||||
longPressUrlCopy: settings.tabBarLongPressUrlCopy,
|
||||
containerColor: containerColor,
|
||||
sandboxSourceUri: sandboxSourceUri,
|
||||
onSiteSettingsTap: () {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider.notifier)
|
||||
@@ -265,7 +287,7 @@ class AppBarTitle extends ConsumerWidget {
|
||||
onTitleTap: () async {
|
||||
await SearchRoute(
|
||||
tabId: tabState.id,
|
||||
searchText: _searchTextForTab(tabState),
|
||||
searchText: searchTextForTab(tabState, sandboxSourceUri),
|
||||
tabType: tabState.tabMode.toTabType(),
|
||||
).push(context);
|
||||
},
|
||||
@@ -284,6 +306,7 @@ class AppBarTitleView extends StatelessWidget {
|
||||
required this.longPressUrlCopy,
|
||||
this.tabIcon,
|
||||
this.containerColor,
|
||||
this.sandboxSourceUri,
|
||||
});
|
||||
|
||||
final TabState tabState;
|
||||
@@ -294,12 +317,16 @@ class AppBarTitleView extends StatelessWidget {
|
||||
final Widget? tabIcon;
|
||||
final bool longPressUrlCopy;
|
||||
final Color? containerColor;
|
||||
final Uri? sandboxSourceUri;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final appColors = AppColors.of(context);
|
||||
final containerColor = this.containerColor;
|
||||
final containerPalette = containerColor != null
|
||||
? ContainerColors.palette(context, containerColor)
|
||||
: null;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
@@ -370,17 +397,11 @@ class AppBarTitleView extends StatelessWidget {
|
||||
: EdgeInsets.zero,
|
||||
decoration: BoxDecoration(
|
||||
color: containerColor != null
|
||||
? Color.alphaBlend(
|
||||
containerColor.withValues(alpha: 0.08),
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
)
|
||||
? containerPalette!.surfaceColor
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: containerColor != null
|
||||
? Border.all(
|
||||
color: containerColor.withValues(alpha: 0.5),
|
||||
width: 2,
|
||||
)
|
||||
border: containerPalette != null
|
||||
? Border.all(color: containerPalette.outlineColor)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
@@ -404,15 +425,23 @@ class AppBarTitleView extends StatelessWidget {
|
||||
const Icon(MdiIcons.tunnelOutline, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
_SecurityStatusIcon(
|
||||
tabState: tabState,
|
||||
size: 14,
|
||||
containerColor: containerColor,
|
||||
),
|
||||
if (sandboxSourceUri != null) ...[
|
||||
Icon(
|
||||
MdiIcons.archiveLockOutline,
|
||||
color: theme.colorScheme.tertiary,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
] else
|
||||
_SecurityStatusIcon(
|
||||
tabState: tabState,
|
||||
size: 14,
|
||||
containerColor: containerPalette?.accentColor,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: UriBreadcrumb(
|
||||
uri: tabState.url,
|
||||
uri: sandboxSourceUri ?? tabState.url,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
@@ -420,7 +449,8 @@ class AppBarTitleView extends StatelessWidget {
|
||||
? () async {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: tabState.url.toString(),
|
||||
text: (sandboxSourceUri ?? tabState.url)
|
||||
.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -509,11 +539,3 @@ class _EmptyAppBarAddressField extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _searchTextForTab(TabState tabState) {
|
||||
final searchText = tabState.url.scheme == 'about'
|
||||
? ''
|
||||
: tabState.url.toString();
|
||||
|
||||
return searchText.isEmpty ? SearchRoute.emptySearchText : searchText;
|
||||
}
|
||||
|
||||
+125
-48
@@ -27,6 +27,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:weblibre/core/design/app_colors.dart';
|
||||
import 'package:weblibre/features/addons/presentation/widgets/pinned_addon_bar.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
@@ -50,6 +51,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'
|
||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
|
||||
@@ -240,6 +242,9 @@ class BrowserTabBar extends HookConsumerWidget {
|
||||
displayedSheet is! ViewTabsSheet)
|
||||
? containerColor
|
||||
: null;
|
||||
final effectiveContainerPalette = effectiveContainerColor != null
|
||||
? ContainerColors.palette(context, effectiveContainerColor)
|
||||
: null;
|
||||
|
||||
return BrowserTabBarView(
|
||||
showMainToolbar: showMainToolbar,
|
||||
@@ -247,7 +252,7 @@ class BrowserTabBar extends HookConsumerWidget {
|
||||
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
|
||||
displayAppBar: displayAppBar,
|
||||
displayQuickTabSwitcher: displayQuickTabSwitcher,
|
||||
backgroundColor: null,
|
||||
backgroundColor: effectiveContainerPalette?.surfaceColor,
|
||||
title: showTabTitle
|
||||
? settings.tabBarLayout == TabBarLayout.compact
|
||||
? CompactAppBarTitle(containerColor: effectiveContainerColor)
|
||||
@@ -395,6 +400,8 @@ class BrowserTabBarView extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final effectiveBackgroundColor =
|
||||
backgroundColor ?? colorScheme.surfaceContainer;
|
||||
|
||||
return GestureDetector(
|
||||
// Tap handling moved to AppBarTitle for split icon/title behavior
|
||||
@@ -402,36 +409,38 @@ class BrowserTabBarView extends StatelessWidget {
|
||||
onHorizontalDragEnd: onHorizontalDragEnd,
|
||||
onVerticalDragStart: onVerticalDragStart,
|
||||
onVerticalDragEnd: onVerticalDragEnd,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showQuickTabSwitcherBar)
|
||||
Visibility(
|
||||
visible: displayQuickTabSwitcher,
|
||||
maintainState: true,
|
||||
child: quickTabSwitcher,
|
||||
),
|
||||
if (showMainToolbar)
|
||||
Visibility(
|
||||
visible: displayAppBar,
|
||||
maintainState: true,
|
||||
child: AppBar(
|
||||
primary: false,
|
||||
automaticallyImplyLeading: false,
|
||||
backgroundColor:
|
||||
backgroundColor ?? colorScheme.surfaceContainer,
|
||||
scrolledUnderElevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
titleSpacing: 0.0,
|
||||
leadingWidth: 40.0,
|
||||
toolbarHeight: kToolbarHeight,
|
||||
title: title,
|
||||
actions: actions,
|
||||
child: ColoredBox(
|
||||
color: effectiveBackgroundColor,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showQuickTabSwitcherBar)
|
||||
Visibility(
|
||||
visible: displayQuickTabSwitcher,
|
||||
maintainState: true,
|
||||
child: quickTabSwitcher,
|
||||
),
|
||||
),
|
||||
if (showContextualToolbar) contextualToolbar,
|
||||
],
|
||||
if (showMainToolbar)
|
||||
Visibility(
|
||||
visible: displayAppBar,
|
||||
maintainState: true,
|
||||
child: AppBar(
|
||||
primary: false,
|
||||
automaticallyImplyLeading: false,
|
||||
backgroundColor: Colors.transparent,
|
||||
scrolledUnderElevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
titleSpacing: 0.0,
|
||||
leadingWidth: 40.0,
|
||||
toolbarHeight: kToolbarHeight,
|
||||
title: title,
|
||||
actions: actions,
|
||||
),
|
||||
),
|
||||
if (showContextualToolbar) contextualToolbar,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -444,6 +453,7 @@ class QuickTabSwitcherItem with FastEquatable {
|
||||
final TabMode tabMode;
|
||||
final bool isHistory;
|
||||
final bool isPinned;
|
||||
final bool isSandbox;
|
||||
final String title;
|
||||
final Uri url;
|
||||
final Widget avatar;
|
||||
@@ -458,6 +468,7 @@ class QuickTabSwitcherItem with FastEquatable {
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.avatar,
|
||||
this.isSandbox = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -468,6 +479,7 @@ class QuickTabSwitcherItem with FastEquatable {
|
||||
tabMode,
|
||||
isHistory,
|
||||
isPinned,
|
||||
isSandbox,
|
||||
title,
|
||||
url,
|
||||
avatar,
|
||||
@@ -504,20 +516,31 @@ class QuickTabSwitcher extends HookConsumerWidget {
|
||||
final historySuggestions = ref
|
||||
.watch(quickTabSwitcherHistorySuggestionsProvider(quickTabSwitcherMode))
|
||||
.value;
|
||||
final sandboxCaptureMap =
|
||||
ref.watch(sandboxCaptureMapProvider).value ?? const {};
|
||||
final availableItems = tabStates.value
|
||||
.map<QuickTabSwitcherItem>(
|
||||
(state) => QuickTabSwitcherItem(
|
||||
.map<QuickTabSwitcherItem>((state) {
|
||||
final sandboxSourceUri = parseSandboxSource(
|
||||
sandboxCaptureMap[state.$1.id],
|
||||
);
|
||||
final displayUrl = sandboxSourceUri ?? state.$1.url;
|
||||
final displayTitle =
|
||||
sandboxSourceUri != null && state.$1.title.isEmpty
|
||||
? sandboxSourceUri.authority
|
||||
: state.$1.titleOrAuthority;
|
||||
return QuickTabSwitcherItem(
|
||||
color: state.$2?.color,
|
||||
id: state.$1.id,
|
||||
isActive: state.$1.id == selectedTabId,
|
||||
title: state.$1.titleOrAuthority,
|
||||
title: displayTitle,
|
||||
tabMode: state.$1.tabMode,
|
||||
isHistory: false,
|
||||
isPinned: pinnedTabIds?.contains(state.$1.id) ?? false,
|
||||
url: state.$1.url,
|
||||
isSandbox: sandboxSourceUri != null,
|
||||
url: displayUrl,
|
||||
avatar: TabIcon(tabState: state.$1, iconSize: 20),
|
||||
),
|
||||
)
|
||||
);
|
||||
})
|
||||
.followedBy(
|
||||
(historySuggestions ?? []).map<QuickTabSwitcherItem>((state) {
|
||||
final url = Uri.parse(state.url);
|
||||
@@ -694,6 +717,7 @@ class QuickTabSwitcherView extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appColors = AppColors.of(context);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (availableItems.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
@@ -715,16 +739,42 @@ class QuickTabSwitcherView extends StatelessWidget {
|
||||
itemId: (item) => item.id,
|
||||
selectedItem: activeItem,
|
||||
selectedBorderColor: Theme.of(context).colorScheme.primary,
|
||||
labelPadding: (item) =>
|
||||
(!showTitles &&
|
||||
!item.isHistory &&
|
||||
!item.isPinned &&
|
||||
item.tabMode is! PrivateTabMode &&
|
||||
item.tabMode is! IsolatedTabMode)
|
||||
? EdgeInsets.zero
|
||||
: null,
|
||||
decoration: SelectableChipDecoration(
|
||||
color: (item, isSelected) => switch (item.color) {
|
||||
final color? when isSelected => ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
).selectedBackgroundColor,
|
||||
final color? => ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
).backgroundColor,
|
||||
null => null,
|
||||
},
|
||||
side: (item, isSelected) => switch (item.color) {
|
||||
final color? when isSelected => ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
).selectedBorderSide,
|
||||
final color? => ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
).borderSide,
|
||||
null => null,
|
||||
},
|
||||
labelPadding: (item) =>
|
||||
(!showTitles &&
|
||||
!item.isHistory &&
|
||||
!item.isPinned &&
|
||||
!item.isSandbox &&
|
||||
item.tabMode is! PrivateTabMode &&
|
||||
item.tabMode is! IsolatedTabMode)
|
||||
? EdgeInsets.zero
|
||||
: null,
|
||||
),
|
||||
itemLabel: (item) {
|
||||
return Row(
|
||||
final isSelected = activeItem?.id == item.id;
|
||||
final row = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (item.isHistory || showTitles)
|
||||
@@ -750,6 +800,15 @@ class QuickTabSwitcherView extends StatelessWidget {
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
if (item.isSandbox)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Icon(
|
||||
MdiIcons.archiveLockOutline,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
if (item.isPinned)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
@@ -766,11 +825,29 @@ class QuickTabSwitcherView extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return item.color.mapNotNull(
|
||||
(color) => DefaultTextStyle.merge(
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
).selectedForegroundColor
|
||||
: ContainerColors.palette(
|
||||
context,
|
||||
color,
|
||||
).foregroundColor,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
),
|
||||
child: row,
|
||||
),
|
||||
) ??
|
||||
row;
|
||||
},
|
||||
itemAvatar: (item) => item.avatar,
|
||||
itemBackgroundColor: (item) => item.color != null
|
||||
? ContainerColors.forChip(item.color!)
|
||||
: null,
|
||||
onSelected: onSelected,
|
||||
itemWrap: itemWrapBuilder,
|
||||
availableItems: availableItems,
|
||||
|
||||
+22
@@ -50,6 +50,7 @@ import 'package:weblibre/features/geckoview/features/history/domain/repositories
|
||||
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart';
|
||||
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_pruner.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
@@ -60,6 +61,7 @@ import 'package:weblibre/features/intent_gatekeeper/domain/services/native_gatek
|
||||
import 'package:weblibre/features/intent_gatekeeper/presentation/widgets/intent_gatekeeper_dialog.dart';
|
||||
import 'package:weblibre/features/share_intent/domain/entities/intent_container_mode.dart';
|
||||
import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart';
|
||||
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/providers/profile_auth.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/cache.dart';
|
||||
@@ -132,6 +134,8 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
||||
DateTime(0),
|
||||
DateTime.now().subtract(settings.historyAutoCleanInterval),
|
||||
);
|
||||
|
||||
unawaited(ref.read(localIndexPrunerProvider.notifier).prune());
|
||||
}
|
||||
|
||||
if (settings.unassignedTabsAutoCleanInterval > Duration.zero) {
|
||||
@@ -631,6 +635,24 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
//Ensure tor events don't get dropped
|
||||
ref.listenManual(
|
||||
fireImmediately: true,
|
||||
torProxyServiceProvider,
|
||||
(previous, next) {
|
||||
if (next.hasValue) {
|
||||
debugPrint(next.requireValue.toString());
|
||||
}
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
logger.e(
|
||||
'Error listening to torProxyServiceProvider',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
+19
@@ -24,6 +24,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:weblibre/extensions/uri.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
|
||||
class CertificateTile extends HookConsumerWidget {
|
||||
const CertificateTile({super.key});
|
||||
@@ -36,6 +37,24 @@ class CertificateTile extends HookConsumerWidget {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
);
|
||||
if (sandboxSourceUri != null) {
|
||||
// Sandbox-captured page is served from a loopback server; the cert
|
||||
// chain shown would be for localhost, not the canonical site.
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
MdiIcons.archiveLockOutline,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
),
|
||||
title: const Text('Sandboxed capture'),
|
||||
subtitle: const Text(
|
||||
'Page is served from an offline archive — no live connection.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final icon = useMemoized(() {
|
||||
if (tabState.url.isHttp) {
|
||||
return ListTile(
|
||||
|
||||
+20
-5
@@ -36,6 +36,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/database/definiti
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
|
||||
@@ -51,8 +52,11 @@ class ShareMenuItemButton extends HookConsumerWidget {
|
||||
closeOnActivate: false,
|
||||
onPressed: () async {
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||
final shareUrl =
|
||||
ref.read(sandboxSourceUriForTabProvider(tabId: tabState.id)) ??
|
||||
tabState.url;
|
||||
|
||||
await SharePlus.instance.share(ShareParams(uri: tabState.url));
|
||||
await SharePlus.instance.share(ShareParams(uri: shareUrl));
|
||||
|
||||
if (context.mounted) {
|
||||
MenuController.maybeOf(context)?.close();
|
||||
@@ -75,8 +79,11 @@ class ShowQrCodeMenuItemButton extends HookConsumerWidget {
|
||||
closeOnActivate: false,
|
||||
onPressed: () async {
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||
final qrUrl =
|
||||
ref.read(sandboxSourceUriForTabProvider(tabId: tabState.id)) ??
|
||||
tabState.url;
|
||||
|
||||
await showQrCode(context, tabState.url.toString());
|
||||
await showQrCode(context, qrUrl.toString());
|
||||
|
||||
if (context.mounted) {
|
||||
MenuController.maybeOf(context)?.close();
|
||||
@@ -362,8 +369,11 @@ class CopyAddressMenuItemButton extends HookConsumerWidget {
|
||||
child: const Text('Copy Address'),
|
||||
onPressed: () async {
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||
final copyUrl =
|
||||
ref.read(sandboxSourceUriForTabProvider(tabId: tabState.id)) ??
|
||||
tabState.url;
|
||||
|
||||
await Clipboard.setData(ClipboardData(text: tabState.url.toString()));
|
||||
await Clipboard.setData(ClipboardData(text: copyUrl.toString()));
|
||||
|
||||
if (context.mounted) {
|
||||
MenuController.maybeOf(context)?.close();
|
||||
@@ -419,16 +429,21 @@ class SendTabToDeviceMenuItemButton extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final sendUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
) ??
|
||||
tabState.url;
|
||||
final title = tabState.title.isNotEmpty
|
||||
? tabState.title
|
||||
: tabState.url.toString();
|
||||
: sendUrl.toString();
|
||||
|
||||
final success = await ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.sendTabToDevice(
|
||||
deviceId: device.deviceId,
|
||||
title: title,
|
||||
url: tabState.url.toString(),
|
||||
url: sendUrl.toString(),
|
||||
private: tabState.tabMode == TabMode.private,
|
||||
);
|
||||
|
||||
|
||||
+17
-3
@@ -39,6 +39,7 @@ import 'package:weblibre/features/geckoview/features/open_link_tools/presentatio
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
@@ -67,9 +68,15 @@ class ShareBottomSheet extends HookConsumerWidget {
|
||||
final settings = ref.watch(generalSettingsWithDefaultsProvider);
|
||||
final catalogAsync = ref.watch(urlCleanerCatalogServiceProvider);
|
||||
|
||||
final tabUrl = ref.watch(
|
||||
final rawTabUrl = ref.watch(
|
||||
tabStateProvider(selectedTabId).select((v) => v?.url),
|
||||
);
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: selectedTabId),
|
||||
);
|
||||
// For sandbox-captured tabs every share/copy/QR/cleaner action must
|
||||
// operate on the canonical source URL — never the loopback loader.
|
||||
final tabUrl = sandboxSourceUri ?? rawTabUrl;
|
||||
|
||||
final cleanedUrl = useState<Uri?>(null);
|
||||
final cleaner = useUrlCleanerController(
|
||||
@@ -421,16 +428,23 @@ class _SendToDeviceTile extends ConsumerWidget {
|
||||
);
|
||||
if (tabState == null) return;
|
||||
|
||||
final sendUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(
|
||||
tabId: tabState.id,
|
||||
),
|
||||
) ??
|
||||
tabState.url;
|
||||
final title = tabState.title.isNotEmpty
|
||||
? tabState.title
|
||||
: tabState.url.toString();
|
||||
: sendUrl.toString();
|
||||
|
||||
final success = await ref
|
||||
.read(syncRepositoryProvider.notifier)
|
||||
.sendTabToDevice(
|
||||
deviceId: device.deviceId,
|
||||
title: title,
|
||||
url: tabState.url.toString(),
|
||||
url: sendUrl.toString(),
|
||||
private: tabState.tabMode == TabMode.private,
|
||||
);
|
||||
|
||||
|
||||
+10
-7
@@ -30,6 +30,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/permissions_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/tracking_protection_section.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/widgets/website_title_tile.dart';
|
||||
|
||||
class ClampingScrollPhysicsWithoutImplicit extends ClampingScrollPhysics {
|
||||
@@ -112,16 +113,18 @@ class ViewTabSheetWidget extends HookConsumerWidget {
|
||||
// Dismiss sheet and open search screen with tab context
|
||||
onClose();
|
||||
|
||||
// Don't pre-fill for internal URLs
|
||||
final searchText = initialTabState.url.scheme == 'about'
|
||||
? ''
|
||||
: initialTabState.url.toString();
|
||||
final sandboxSourceUri = ref.read(
|
||||
sandboxSourceUriForTabProvider(
|
||||
tabId: initialTabState.id,
|
||||
),
|
||||
);
|
||||
|
||||
await SearchRoute(
|
||||
tabId: initialTabState.id,
|
||||
searchText: searchText.isEmpty
|
||||
? SearchRoute.emptySearchText
|
||||
: searchText,
|
||||
searchText: searchTextForTab(
|
||||
initialTabState,
|
||||
sandboxSourceUri,
|
||||
),
|
||||
tabType: initialTabState.tabMode.toTabType(),
|
||||
).push(context);
|
||||
},
|
||||
|
||||
+10
@@ -25,8 +25,10 @@ import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:weblibre/domain/entities/equatable_image.dart';
|
||||
import 'package:weblibre/domain/services/generic_website.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
|
||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||
|
||||
class TabIcon extends HookConsumerWidget {
|
||||
final TabState tabState;
|
||||
@@ -37,6 +39,10 @@ class TabIcon extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
);
|
||||
|
||||
final icon = useCachedFuture(() async {
|
||||
if (tabState.icon case final EquatableImage tabIcon
|
||||
when !tabIcon.isDisposed) {
|
||||
@@ -50,6 +56,10 @@ class TabIcon extends HookConsumerWidget {
|
||||
return cachedIcon?.image;
|
||||
}, [tabState.icon, tabState.url]);
|
||||
|
||||
if (sandboxSourceUri != null) {
|
||||
return UrlIcon([sandboxSourceUri], iconSize: iconSize, cacheOnly: true);
|
||||
}
|
||||
|
||||
return Skeletonizer(
|
||||
enabled: icon.connectionState != ConnectionState.done,
|
||||
child: Skeleton.replace(
|
||||
|
||||
+25
-4
@@ -51,6 +51,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
||||
import 'package:weblibre/presentation/widgets/website_feed_menu_button.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||
@@ -196,12 +197,17 @@ class TabMenu extends HookConsumerWidget {
|
||||
child: const Text('Add Bookmark'),
|
||||
onPressed: () async {
|
||||
final tabState = ref.read(tabStateProvider(selectedTabId))!;
|
||||
final bookmarkUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
) ??
|
||||
tabState.url;
|
||||
|
||||
await BookmarkEntryAddRoute(
|
||||
bookmarkInfo: jsonEncode(
|
||||
BookmarkInfo(
|
||||
title: tabState.titleOrAuthority,
|
||||
url: tabState.url.toString(),
|
||||
url: bookmarkUrl.toString(),
|
||||
).encode(),
|
||||
),
|
||||
).push(context);
|
||||
@@ -248,12 +254,17 @@ class TabMenu extends HookConsumerWidget {
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerData(selectedTabId);
|
||||
|
||||
final cloneUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
) ??
|
||||
tabState.url;
|
||||
final tabId = (tabState.tabMode is! RegularTabMode)
|
||||
? await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
tabMode: TabMode.regular,
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(
|
||||
@@ -294,11 +305,16 @@ class TabMenu extends HookConsumerWidget {
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerData(selectedTabId);
|
||||
|
||||
final cloneUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
) ??
|
||||
tabState.url;
|
||||
final tabId = (tabState.tabMode is! PrivateTabMode)
|
||||
? await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
tabMode: TabMode.private,
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
@@ -341,10 +357,15 @@ class TabMenu extends HookConsumerWidget {
|
||||
.read(tabDataRepositoryProvider.notifier)
|
||||
.getTabContainerData(selectedTabId);
|
||||
|
||||
final cloneUrl =
|
||||
ref.read(
|
||||
sandboxSourceUriForTabProvider(tabId: tabState.id),
|
||||
) ??
|
||||
tabState.url;
|
||||
final tabId = await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: tabState.url,
|
||||
url: cloneUrl,
|
||||
tabMode: TabMode.newIsolated(),
|
||||
containerSelection: containerData == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
|
||||
+23
-6
@@ -36,6 +36,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
||||
import 'package:weblibre/presentation/widgets/safe_raw_image.dart';
|
||||
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
|
||||
@@ -173,6 +174,14 @@ class GridTabPreview extends HookConsumerWidget {
|
||||
) ??
|
||||
TabState.$default(tabId);
|
||||
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: tabId),
|
||||
);
|
||||
final displayUrl = sandboxSourceUri ?? tabState.url;
|
||||
final displayTitle = sandboxSourceUri != null && tabState.title.isEmpty
|
||||
? sandboxSourceUri.authority
|
||||
: tabState.titleOrAuthority;
|
||||
|
||||
final extendedDeleteMenuController = useMenuController();
|
||||
|
||||
// ignore: avoid_bool_literals_in_conditional_expressions
|
||||
@@ -249,7 +258,7 @@ class GridTabPreview extends HookConsumerWidget {
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
onPressed: () {
|
||||
onDeleteAll?.call(tabState.url.host);
|
||||
onDeleteAll?.call(displayUrl.host);
|
||||
},
|
||||
leadingIcon: const Icon(Icons.language),
|
||||
child: const Text('Close from Same Host'),
|
||||
@@ -389,7 +398,7 @@ class GridTabPreview extends HookConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tabState.titleOrAuthority,
|
||||
displayTitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
@@ -404,7 +413,7 @@ class GridTabPreview extends HookConsumerWidget {
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
tabState.url.authority,
|
||||
displayUrl.authority,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
@@ -477,6 +486,14 @@ class ListTabPreview extends HookConsumerWidget {
|
||||
) ??
|
||||
TabState.$default(tabId);
|
||||
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: tabId),
|
||||
);
|
||||
final displayUrl = sandboxSourceUri ?? tabState.url;
|
||||
final displayTitle = sandboxSourceUri != null && tabState.title.isEmpty
|
||||
? sandboxSourceUri.authority
|
||||
: tabState.titleOrAuthority;
|
||||
|
||||
final tabListShowFavicons = ref.watch(
|
||||
generalSettingsWithDefaultsProvider.select((s) => s.tabListShowFavicons),
|
||||
);
|
||||
@@ -563,7 +580,7 @@ class ListTabPreview extends HookConsumerWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
tabState.titleOrAuthority,
|
||||
displayTitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
@@ -592,7 +609,7 @@ class ListTabPreview extends HookConsumerWidget {
|
||||
],
|
||||
Expanded(
|
||||
child: UriBreadcrumb(
|
||||
uri: tabState.url,
|
||||
uri: displayUrl,
|
||||
showHttpScheme: false,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: subtitleColor,
|
||||
@@ -616,7 +633,7 @@ class ListTabPreview extends HookConsumerWidget {
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
onPressed: () {
|
||||
onDeleteAll?.call(tabState.url.host);
|
||||
onDeleteAll?.call(displayUrl.host);
|
||||
},
|
||||
leadingIcon: const Icon(Icons.language),
|
||||
child: const Text('Close from Same Host'),
|
||||
|
||||
+18
-11
@@ -20,6 +20,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -302,18 +303,24 @@ class ViewTabTreesWidget extends HookConsumerWidget {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: GridView.builder(
|
||||
child: FadingScroll(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.only(bottom: 56),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
//Sync values for itemHeight calculation _calculateItemHeight
|
||||
childAspectRatio: 0.75,
|
||||
mainAxisSpacing: 8.0,
|
||||
crossAxisSpacing: 8.0,
|
||||
crossAxisCount: crossAxisCount,
|
||||
),
|
||||
itemCount: tabs.length,
|
||||
itemBuilder: (context, index) => tabs[index],
|
||||
fadingSize: 5,
|
||||
builder: (context, controller) {
|
||||
return GridView.builder(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.only(bottom: 56),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
//Sync values for itemHeight calculation _calculateItemHeight
|
||||
childAspectRatio: 0.75,
|
||||
mainAxisSpacing: 8.0,
|
||||
crossAxisSpacing: 8.0,
|
||||
crossAxisCount: crossAxisCount,
|
||||
),
|
||||
itemCount: tabs.length,
|
||||
itemBuilder: (context, index) => tabs[index],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
+2
-1
@@ -184,7 +184,8 @@ class _ContainerPickerSheet extends HookConsumerWidget {
|
||||
itemBuilder: (context, index) => ContainerListTile(
|
||||
ContainerData(
|
||||
id: Namespace.nil.value,
|
||||
color: Colors.transparent,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
orderKey: '',
|
||||
),
|
||||
isSelected: false,
|
||||
onTap: null,
|
||||
|
||||
+5
-4
@@ -17,6 +17,7 @@
|
||||
* 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:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
@@ -26,7 +27,7 @@ import 'package:weblibre/features/geckoview/features/find_in_page/domain/reposit
|
||||
|
||||
part 'find_in_page.g.dart';
|
||||
|
||||
@Riverpod()
|
||||
@Riverpod(keepAlive: true)
|
||||
class FindInPageController extends _$FindInPageController {
|
||||
void show() {
|
||||
state = state.copyWith.visible(true);
|
||||
@@ -49,7 +50,7 @@ class FindInPageController extends _$FindInPageController {
|
||||
final service = ref.read(findInPageRepositoryProvider(tabId).notifier);
|
||||
|
||||
final hasMatches =
|
||||
ref.read(selectedTabStateProvider)?.findResultState.hasMatches == true;
|
||||
ref.read(tabStatesProvider)[tabId]?.findResultState.hasMatches == true;
|
||||
|
||||
state = state.copyWith.visible(true);
|
||||
|
||||
@@ -70,7 +71,7 @@ class FindInPageController extends _$FindInPageController {
|
||||
FindInPageState build(String tabId) {
|
||||
ref.listen(
|
||||
fireImmediately: true,
|
||||
tabStateProvider(tabId),
|
||||
tabStatesProvider.select((tabs) => tabs[tabId]),
|
||||
(previous, next) async {
|
||||
if (!ref.mounted) return;
|
||||
//Ensure state is already initialized
|
||||
@@ -89,7 +90,7 @@ class FindInPageController extends _$FindInPageController {
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
logger.e(
|
||||
'Error listening to selectedTabStateProvider',
|
||||
'Error listening to tabStatesProvider',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
+3
-3
@@ -20,7 +20,7 @@ final class FindInPageControllerProvider
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'findInPageControllerProvider',
|
||||
isAutoDispose: true,
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
@@ -59,7 +59,7 @@ final class FindInPageControllerProvider
|
||||
}
|
||||
|
||||
String _$findInPageControllerHash() =>
|
||||
r'4aaa0c4b4a623e7274836e70e5f66e97a3ebf80a';
|
||||
r'21ca6178016dc141fcfcb71afc158d9b8eed0a7e';
|
||||
|
||||
final class FindInPageControllerFamily extends $Family
|
||||
with
|
||||
@@ -76,7 +76,7 @@ final class FindInPageControllerFamily extends $Family
|
||||
name: r'findInPageControllerProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
isAutoDispose: false,
|
||||
);
|
||||
|
||||
FindInPageControllerProvider call(String tabId) =>
|
||||
|
||||
+16
@@ -49,6 +49,22 @@ class FindInPageWidget extends HookConsumerWidget {
|
||||
text: searchResult?.lastSearchText ?? findInPageState.lastSearchText,
|
||||
);
|
||||
|
||||
// Sync text field when the find-in-page query is set externally (e.g.,
|
||||
// from a tab/history search hit). Comparing previous-vs-next on the
|
||||
// riverpod state, rather than the controller text, lets the user keep
|
||||
// an empty field after clearing without us repopulating it.
|
||||
ref.listen(
|
||||
findInPageControllerProvider(tabId).select((s) => s.lastSearchText),
|
||||
(previous, next) {
|
||||
if (next != null && next != previous && textController.text != next) {
|
||||
textController.text = next;
|
||||
textController.selection = TextSelection.collapsed(
|
||||
offset: next.length,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Create debouncer with automatic disposal
|
||||
final debouncer = useDebouncer(const Duration(milliseconds: 300));
|
||||
|
||||
|
||||
+67
@@ -81,6 +81,73 @@ class HistoryRepository extends _$HistoryRepository {
|
||||
);
|
||||
}
|
||||
|
||||
Future<HistoryMetadata?> getLatestHistoryMetadataForUrl(String url) {
|
||||
return _service.getLatestHistoryMetadataForUrl(url);
|
||||
}
|
||||
|
||||
Future<List<HistoryMetadata?>> getLatestHistoryMetadataForUrls(
|
||||
List<String> urls,
|
||||
) {
|
||||
return _service.getLatestHistoryMetadataForUrls(urls);
|
||||
}
|
||||
|
||||
Future<List<bool>> getVisited(List<String> urls) {
|
||||
return _service.getVisited(urls);
|
||||
}
|
||||
|
||||
Future<List<HistorySuggestion>> getSuggestions(
|
||||
String query, {
|
||||
int limit = 10,
|
||||
}) {
|
||||
return _service.getSuggestions(query, limit: limit);
|
||||
}
|
||||
|
||||
Future<List<HistoryMetadata>> queryHistoryMetadata(
|
||||
String query, {
|
||||
int limit = 10,
|
||||
}) {
|
||||
return _service.queryHistoryMetadata(query, limit: limit);
|
||||
}
|
||||
|
||||
Future<void> recordObservation(
|
||||
String url, {
|
||||
String? title,
|
||||
String? previewImageUrl,
|
||||
}) {
|
||||
return _service.recordObservation(
|
||||
url,
|
||||
title: title,
|
||||
previewImageUrl: previewImageUrl,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> noteViewTime(HistoryMetadataKey key, Duration viewTime) {
|
||||
return _service.noteHistoryMetadataViewTime(key, viewTime);
|
||||
}
|
||||
|
||||
Future<void> noteDocumentType(
|
||||
HistoryMetadataKey key,
|
||||
DocumentType documentType,
|
||||
) {
|
||||
return _service.noteHistoryMetadataDocumentType(key, documentType);
|
||||
}
|
||||
|
||||
Future<void> deleteVisitsFor(String url) {
|
||||
return _service.deleteVisitsFor(url);
|
||||
}
|
||||
|
||||
Future<void> deleteVisitsSince(DateTime since) {
|
||||
return _service.deleteVisitsSince(since);
|
||||
}
|
||||
|
||||
Future<void> deleteEverything() {
|
||||
return _service.deleteEverything();
|
||||
}
|
||||
|
||||
Future<void> deleteHistoryMetadataOlderThan(DateTime olderThan) {
|
||||
return _service.deleteHistoryMetadataOlderThan(olderThan);
|
||||
}
|
||||
|
||||
@override
|
||||
void build() {}
|
||||
}
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ final class HistoryRepositoryProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$historyRepositoryHash() => r'414b68ec6be3cc2eca3681cbc00990e53e6127ce';
|
||||
String _$historyRepositoryHash() => r'4fec6cf4ef7cdfcabf3ccfd4a43fbf634bfe377f';
|
||||
|
||||
abstract class _$HistoryRepository extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
+1
@@ -455,6 +455,7 @@ class OpenSharedContent extends HookConsumerWidget {
|
||||
alignment: Alignment.centerRight,
|
||||
child: CompactContainerSelector(
|
||||
selectedContainer: selectedContainer.value,
|
||||
emphasizeSelection: false,
|
||||
onSelectionChanged: (selection) async {
|
||||
containerSelectionTouched.value = true;
|
||||
switch (selection) {
|
||||
|
||||
+70
-62
@@ -17,6 +17,7 @@
|
||||
* 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:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/entities/url_cleaner_result.dart';
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_service.dart';
|
||||
@@ -103,72 +104,79 @@ class _TrackingDetailsDialogState extends State<TrackingDetailsDialog> {
|
||||
const SizedBox(height: 16),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 220),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: _items.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
Divider(height: 1, color: colorScheme.outlineVariant),
|
||||
itemBuilder: (context, index) {
|
||||
final item = _items[index];
|
||||
final display = _splitMatch(item.match);
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView.separated(
|
||||
controller: controller,
|
||||
shrinkWrap: true,
|
||||
itemCount: _items.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
Divider(height: 1, color: colorScheme.outlineVariant),
|
||||
itemBuilder: (context, index) {
|
||||
final item = _items[index];
|
||||
final display = _splitMatch(item.match);
|
||||
|
||||
return CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
activeColor: colorScheme.primary,
|
||||
checkColor: colorScheme.onPrimary,
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
display.key,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (item.type == UrlCleanerMatchType.referralRule)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'Referral marketing',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontSize: 11,
|
||||
return CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
activeColor: colorScheme.primary,
|
||||
checkColor: colorScheme.onPrimary,
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
display.key,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
subtitle: display.value == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
display.value!,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
if (item.type ==
|
||||
UrlCleanerMatchType.referralRule)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'Referral marketing',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
value: _selected[index],
|
||||
onChanged: canApply
|
||||
? (checked) {
|
||||
setState(() {
|
||||
_selected[index] = checked ?? false;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
subtitle: display.value == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
display.value!,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
value: _selected[index],
|
||||
onChanged: canApply
|
||||
? (checked) {
|
||||
setState(() {
|
||||
_selected[index] = checked ?? false;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
+49
-24
@@ -19,7 +19,6 @@
|
||||
*/
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
@@ -27,37 +26,63 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/extensions/uri.dart';
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/utils/open_in_custom_tab.dart';
|
||||
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
|
||||
const List<SettingsSectionDefinition> unshortenerSettingsSections = [
|
||||
SettingsSectionDefinition(
|
||||
title: 'Overview',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Description',
|
||||
subtitle: 'Resolve shortened URLs using the unshorten.me service',
|
||||
keywords: ['short links', 'redirects'],
|
||||
child: _UnshortenerDescriptionTile(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'Behavior',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Enable Unshortener',
|
||||
subtitle: 'Resolve shortened URLs to their destination',
|
||||
keywords: ['short links'],
|
||||
child: _UnshortenerEnabledTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'API Token',
|
||||
subtitle: 'Optional token for higher request limits',
|
||||
keywords: ['token'],
|
||||
child: _UnshortenerTokenField(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'Attribution',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Service attribution',
|
||||
subtitle: 'Rate limits, service homepage, and privacy policy',
|
||||
keywords: ['privacy policy', 'rate limit'],
|
||||
child: _UnshortenerAttributionTile(),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
class UnshortenerSettingsScreen extends StatelessWidget {
|
||||
const UnshortenerSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Unshortener')),
|
||||
body: SafeArea(
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
children: const [
|
||||
SettingSection(name: 'Unshortener'),
|
||||
_UnshortenerDescriptionTile(),
|
||||
SettingSection(name: 'Behavior'),
|
||||
_UnshortenerEnabledTile(),
|
||||
_UnshortenerTokenField(),
|
||||
SettingSection(name: 'Attribution'),
|
||||
_UnshortenerAttributionTile(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
return const SettingsDetailScaffold(
|
||||
title: 'Unshortener',
|
||||
subtitle:
|
||||
'Short-link resolution behavior, token configuration, and attribution.',
|
||||
icon: MdiIcons.linkVariant,
|
||||
sections: unshortenerSettingsSections,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+73
-29
@@ -17,7 +17,6 @@
|
||||
* 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:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
@@ -27,43 +26,88 @@ import 'package:weblibre/features/geckoview/features/open_link_tools/domain/serv
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/url_cleaner_restore_defaults_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/attribution_link.dart';
|
||||
import 'package:weblibre/features/settings/presentation/controllers/save_settings.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/sections.dart';
|
||||
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/utils/ui_helper.dart';
|
||||
|
||||
const List<SettingsSectionDefinition> urlCleanerSettingsSections = [
|
||||
SettingsSectionDefinition(
|
||||
title: 'Overview',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Description',
|
||||
subtitle: 'Tracking parameter removal and offline redirect cleanup',
|
||||
keywords: ['tracking parameters', 'redirects'],
|
||||
child: _UrlCleanerDescriptionTile(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'Behavior',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Enable URL Cleaner',
|
||||
subtitle: 'Remove tracking parameters from URLs',
|
||||
keywords: ['clean urls'],
|
||||
child: _UrlCleanerEnabledTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Auto-apply',
|
||||
subtitle: 'Automatically replace URL with cleaned version',
|
||||
keywords: ['auto apply'],
|
||||
child: _UrlCleanerAutoApplyTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Allow referral marketing',
|
||||
subtitle: 'Keep referral and affiliate tracking parameters',
|
||||
keywords: ['affiliate', 'referral'],
|
||||
child: _UrlCleanerAllowReferralTile(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'Catalog',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Auto-update catalog',
|
||||
subtitle: 'Check for rule updates weekly',
|
||||
child: _UrlCleanerAutoUpdateTile(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Update catalog',
|
||||
subtitle: 'Fetch the latest URL cleaner rules',
|
||||
child: _UrlCleanerUpdateButton(),
|
||||
),
|
||||
SettingsEntryDefinition(
|
||||
title: 'Restore defaults',
|
||||
subtitle: 'Reset to bundled catalog and default settings',
|
||||
child: _UrlCleanerRestoreDefaultsButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSectionDefinition(
|
||||
title: 'Attribution',
|
||||
entries: [
|
||||
SettingsEntryDefinition(
|
||||
title: 'Attribution',
|
||||
subtitle: 'Credits and source links',
|
||||
child: _UrlCleanerAttributionTile(),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
class UrlCleanerSettingsScreen extends StatelessWidget {
|
||||
const UrlCleanerSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('URL Cleaner')),
|
||||
body: SafeArea(
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
children: const [
|
||||
SettingSection(name: 'URL Cleaner'),
|
||||
_UrlCleanerDescriptionTile(),
|
||||
SettingSection(name: 'Behavior'),
|
||||
_UrlCleanerEnabledTile(),
|
||||
_UrlCleanerAutoApplyTile(),
|
||||
_UrlCleanerAllowReferralTile(),
|
||||
SettingSection(name: 'Catalog'),
|
||||
_UrlCleanerAutoUpdateTile(),
|
||||
_UrlCleanerUpdateButton(),
|
||||
_UrlCleanerRestoreDefaultsButton(),
|
||||
SettingSection(name: 'Attribution'),
|
||||
_UrlCleanerAttributionTile(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
return const SettingsDetailScaffold(
|
||||
title: 'URL Cleaner',
|
||||
subtitle: 'URL cleanup behavior, rule catalog updates, and attribution.',
|
||||
icon: MdiIcons.broom,
|
||||
sections: urlCleanerSettingsSections,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/features/pwa/domain/pwa_installability.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/sandbox_capture_controller.dart';
|
||||
|
||||
part 'providers.g.dart';
|
||||
|
||||
@@ -83,6 +84,15 @@ PwaManifest? currentTabManifest(Ref ref) {
|
||||
/// Boolean indicating if the current tab is installable as a PWA.
|
||||
@Riverpod()
|
||||
bool isCurrentTabInstallable(Ref ref) {
|
||||
final selectedTabId = ref.watch(selectedTabProvider);
|
||||
// Sandbox-captured tabs serve a loopback page; "installing" it would
|
||||
// either pin the loopback URL (broken after the capture server cycles)
|
||||
// or, worse, silently navigate the source URL.
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: selectedTabId),
|
||||
);
|
||||
if (sandboxSourceUri != null) return false;
|
||||
|
||||
final manifest = ref.watch(currentTabManifestProvider);
|
||||
|
||||
if (manifest == null) return false;
|
||||
@@ -126,6 +136,13 @@ bool isCurrentTabShortcutable(Ref ref) {
|
||||
final selectedTabId = ref.watch(selectedTabProvider);
|
||||
if (selectedTabId == null) return false;
|
||||
|
||||
// Same reasoning as `isCurrentTabInstallable`: never offer to pin a
|
||||
// sandbox-captured tab to the home screen.
|
||||
final sandboxSourceUri = ref.watch(
|
||||
sandboxSourceUriForTabProvider(tabId: selectedTabId),
|
||||
);
|
||||
if (sandboxSourceUri != null) return false;
|
||||
|
||||
final tabState = ref.watch(tabStateProvider(selectedTabId));
|
||||
if (tabState == null) return false;
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ final class IsCurrentTabInstallableProvider
|
||||
}
|
||||
|
||||
String _$isCurrentTabInstallableHash() =>
|
||||
r'293cdb6dcea24446343330ccdb30dc9f21f03618';
|
||||
r'484c048dca283317d30b3847a32092784dde48be';
|
||||
|
||||
/// Installs the current tab as a PWA, embedding profile and container context
|
||||
/// in the shortcut intent so the PWA reopens with the same isolation.
|
||||
@@ -398,7 +398,7 @@ final class IsCurrentTabShortcutableProvider
|
||||
}
|
||||
|
||||
String _$isCurrentTabShortcutableHash() =>
|
||||
r'a18fa837facc92397dacb79f38bdefd303ff5d00';
|
||||
r'5ffd27964eef44991d16e1c07ce4c1315a676549';
|
||||
|
||||
/// Creates a basic bookmark shortcut on the home screen for the current tab.
|
||||
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/history_query_result.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/history_search.dart';
|
||||
import 'package:weblibre/utils/url_canonical.dart';
|
||||
|
||||
part 'combined_history.g.dart';
|
||||
|
||||
/// Single row in the combined history section.
|
||||
///
|
||||
/// Items come from one of two sources and are deduplicated by canonical
|
||||
/// URL. The engine ordering is preserved; local-only matches are appended
|
||||
/// after, so the user keeps their familiar frecency-ranked top of list and
|
||||
/// content-search hits flesh out the long tail.
|
||||
class CombinedHistoryItem {
|
||||
final Uri uri;
|
||||
final String? title;
|
||||
final Uint8List? engineIcon;
|
||||
|
||||
/// Raw highlight markers (`***foo***`) from FTS5 over `extracted_content`
|
||||
/// or `full_content`. Renderer in `text_highlight.dart` converts them into
|
||||
/// styled spans. `null` for engine-only items.
|
||||
final String? highlightedTitle;
|
||||
final String? snippet;
|
||||
|
||||
/// Bookkeeping for the renderer / future "Engine"/"Local" badges.
|
||||
final CombinedHistorySource source;
|
||||
|
||||
const CombinedHistoryItem({
|
||||
required this.uri,
|
||||
required this.title,
|
||||
required this.engineIcon,
|
||||
required this.highlightedTitle,
|
||||
required this.snippet,
|
||||
required this.source,
|
||||
});
|
||||
}
|
||||
|
||||
enum CombinedHistorySource { engine, local }
|
||||
|
||||
/// Engine suggestions, augmented per-row with local snippet/highlight when
|
||||
/// available, then padded with local-only matches.
|
||||
///
|
||||
/// Implementation note: kept as a synchronous Riverpod provider that derives
|
||||
/// its data from `engineSuggestionsProvider` + `historySearchRepositoryProvider`.
|
||||
/// Both upstream providers are responsible for kicking off their own queries
|
||||
/// when the search text changes; this one just reacts.
|
||||
@Riverpod()
|
||||
List<CombinedHistoryItem> combinedHistorySuggestions(Ref ref) {
|
||||
final engineAsync = ref.watch(engineSuggestionsProvider);
|
||||
final localAsync = ref.watch(historySearchRepositoryProvider);
|
||||
|
||||
final engineSuggestions =
|
||||
engineAsync.value ?? const <GeckoSuggestion>[];
|
||||
final localResults = localAsync.value?.results ?? const <HistoryQueryResult>[];
|
||||
|
||||
// Index the local rows by canonical URL so engine items can pick up
|
||||
// snippet/title-highlight without an N×M scan.
|
||||
final localByCanonical = <String, HistoryQueryResult>{};
|
||||
for (final hit in localResults) {
|
||||
localByCanonical[hit.urlCanonical] = hit;
|
||||
}
|
||||
|
||||
final emitted = <String>{};
|
||||
final out = <CombinedHistoryItem>[];
|
||||
|
||||
// 1. Engine suggestions in their existing order, enriched where possible.
|
||||
// Single-pass filter+emit: skip suggestions that aren't usable history
|
||||
// items, deduplicate by canonical URL.
|
||||
for (final suggestion in engineSuggestions) {
|
||||
if (suggestion.type != GeckoSuggestionType.history) continue;
|
||||
if (suggestion.title?.isEmpty ?? true) continue;
|
||||
if (suggestion.description?.isEmpty ?? true) continue;
|
||||
|
||||
final uri = suggestion.description.mapNotNull(Uri.tryParse);
|
||||
if (uri == null) continue;
|
||||
final canonical = canonicalizeUrl(uri.toString())?.canonical;
|
||||
if (canonical == null) continue;
|
||||
if (!emitted.add(canonical)) continue;
|
||||
|
||||
final local = localByCanonical[canonical];
|
||||
out.add(
|
||||
CombinedHistoryItem(
|
||||
uri: uri,
|
||||
title: suggestion.title,
|
||||
engineIcon: suggestion.icon,
|
||||
highlightedTitle: local?.title,
|
||||
snippet: _pickSnippet(local),
|
||||
source: CombinedHistorySource.engine,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Local-only matches: URLs the engine didn't surface (typically because
|
||||
// the user's query matched only in extracted/full content rather than
|
||||
// title/url, which is exactly where the local FTS earns its keep).
|
||||
for (final hit in localResults) {
|
||||
if (!emitted.add(hit.urlCanonical)) continue;
|
||||
final uri = Uri.tryParse(hit.urlCanonical);
|
||||
if (uri == null) continue;
|
||||
|
||||
out.add(
|
||||
CombinedHistoryItem(
|
||||
uri: uri,
|
||||
title: hit.title,
|
||||
engineIcon: null,
|
||||
highlightedTitle: hit.title,
|
||||
snippet: _pickSnippet(hit),
|
||||
source: CombinedHistorySource.local,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Prefer the extracted-content snippet (reader text) when it carries a
|
||||
/// match, falling back to full content otherwise. Mirrors the heuristic in
|
||||
/// the existing tab/local-history widgets.
|
||||
String? _pickSnippet(HistoryQueryResult? hit) {
|
||||
if (hit == null) return null;
|
||||
if (hit.extractedContent?.contains(historyHighlightPrefix) ?? false) {
|
||||
return hit.extractedContent;
|
||||
}
|
||||
return hit.fullContent;
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'combined_history.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Engine suggestions, augmented per-row with local snippet/highlight when
|
||||
/// available, then padded with local-only matches.
|
||||
///
|
||||
/// Implementation note: kept as a synchronous Riverpod provider that derives
|
||||
/// its data from `engineSuggestionsProvider` + `historySearchRepositoryProvider`.
|
||||
/// Both upstream providers are responsible for kicking off their own queries
|
||||
/// when the search text changes; this one just reacts.
|
||||
|
||||
@ProviderFor(combinedHistorySuggestions)
|
||||
final combinedHistorySuggestionsProvider =
|
||||
CombinedHistorySuggestionsProvider._();
|
||||
|
||||
/// Engine suggestions, augmented per-row with local snippet/highlight when
|
||||
/// available, then padded with local-only matches.
|
||||
///
|
||||
/// Implementation note: kept as a synchronous Riverpod provider that derives
|
||||
/// its data from `engineSuggestionsProvider` + `historySearchRepositoryProvider`.
|
||||
/// Both upstream providers are responsible for kicking off their own queries
|
||||
/// when the search text changes; this one just reacts.
|
||||
|
||||
final class CombinedHistorySuggestionsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
List<CombinedHistoryItem>,
|
||||
List<CombinedHistoryItem>,
|
||||
List<CombinedHistoryItem>
|
||||
>
|
||||
with $Provider<List<CombinedHistoryItem>> {
|
||||
/// Engine suggestions, augmented per-row with local snippet/highlight when
|
||||
/// available, then padded with local-only matches.
|
||||
///
|
||||
/// Implementation note: kept as a synchronous Riverpod provider that derives
|
||||
/// its data from `engineSuggestionsProvider` + `historySearchRepositoryProvider`.
|
||||
/// Both upstream providers are responsible for kicking off their own queries
|
||||
/// when the search text changes; this one just reacts.
|
||||
CombinedHistorySuggestionsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'combinedHistorySuggestionsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$combinedHistorySuggestionsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<List<CombinedHistoryItem>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
List<CombinedHistoryItem> create(Ref ref) {
|
||||
return combinedHistorySuggestions(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(List<CombinedHistoryItem> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<List<CombinedHistoryItem>>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$combinedHistorySuggestionsHash() =>
|
||||
r'a2cc2bc57529cb60e9ac75e83e3a7babb7f950ea';
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'search_autofocus.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class SearchAutofocusSuppression extends _$SearchAutofocusSuppression {
|
||||
void suppressNext() {
|
||||
state = true;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state = false;
|
||||
}
|
||||
|
||||
@override
|
||||
bool build() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'search_autofocus.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(SearchAutofocusSuppression)
|
||||
final searchAutofocusSuppressionProvider =
|
||||
SearchAutofocusSuppressionProvider._();
|
||||
|
||||
final class SearchAutofocusSuppressionProvider
|
||||
extends $NotifierProvider<SearchAutofocusSuppression, bool> {
|
||||
SearchAutofocusSuppressionProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'searchAutofocusSuppressionProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$searchAutofocusSuppressionHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SearchAutofocusSuppression create() => SearchAutofocusSuppression();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$searchAutofocusSuppressionHash() =>
|
||||
r'088e84ab9af2da9a1adc2316c3c594374720cc9e';
|
||||
|
||||
abstract class _$SearchAutofocusSuppression extends $Notifier<bool> {
|
||||
bool build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<bool, bool>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<bool, bool>,
|
||||
bool,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -58,11 +58,15 @@ List<ModuleOrderEntry> _mergeWithDefaults(
|
||||
final defaultSet = defaults.toSet();
|
||||
// Keep persisted entries that are still valid
|
||||
final result = persisted.where((e) => defaultSet.contains(e.type)).toList();
|
||||
// Add any new defaults not in persisted
|
||||
// Insert any new defaults at their position from the defaults list so newly
|
||||
// introduced modules land where they're meant to (e.g. at the top), instead
|
||||
// of trailing the user's persisted order.
|
||||
final persistedTypes = result.map((e) => e.type).toSet();
|
||||
for (final type in defaults) {
|
||||
for (var i = 0; i < defaults.length; i++) {
|
||||
final type = defaults[i];
|
||||
if (!persistedTypes.contains(type)) {
|
||||
result.add(ModuleOrderEntry(type: type, visible: true));
|
||||
final insertAt = i.clamp(0, result.length);
|
||||
result.insert(insertAt, ModuleOrderEntry(type: type, visible: true));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
+6
@@ -19,16 +19,22 @@ Map<String, dynamic> _$ModuleOrderEntryToJson(ModuleOrderEntry instance) =>
|
||||
};
|
||||
|
||||
const _$SearchModuleTypeEnumMap = {
|
||||
SearchModuleType.recentSearches: 'recentSearches',
|
||||
SearchModuleType.searchProviders: 'searchProviders',
|
||||
SearchModuleType.searchSuggestions: 'searchSuggestions',
|
||||
SearchModuleType.tabs: 'tabs',
|
||||
SearchModuleType.articles: 'articles',
|
||||
SearchModuleType.bookmarks: 'bookmarks',
|
||||
SearchModuleType.history: 'history',
|
||||
SearchModuleType.localHistory: 'localHistory',
|
||||
SearchModuleType.combinedHistory: 'combinedHistory',
|
||||
SearchModuleType.historyHighlights: 'historyHighlights',
|
||||
SearchModuleType.topSites: 'topSites',
|
||||
SearchModuleType.recentHistory: 'recentHistory',
|
||||
SearchModuleType.recentArticles: 'recentArticles',
|
||||
SearchModuleType.recentTabs: 'recentTabs',
|
||||
SearchModuleType.containers: 'containers',
|
||||
SearchModuleType.frequentBangs: 'frequentBangs',
|
||||
};
|
||||
|
||||
// **************************************************************************
|
||||
|
||||
+45
-5
@@ -22,28 +22,58 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
part 'search_modules_view.g.dart';
|
||||
|
||||
enum SearchModuleType {
|
||||
recentSearches,
|
||||
searchProviders,
|
||||
searchSuggestions,
|
||||
tabs,
|
||||
articles,
|
||||
bookmarks,
|
||||
|
||||
/// Engine "History" suggestions, frecency-ranked from Places. Engine-only;
|
||||
/// no local FTS hits. Superseded in the default ordering by
|
||||
/// [combinedHistory] but kept as a separate module for users who want a
|
||||
/// pure engine view.
|
||||
history,
|
||||
|
||||
/// Local FTS5 hits over the indexed `extracted_content` /
|
||||
/// `full_content`. Pure local view; complementary to [history].
|
||||
/// Superseded in the default ordering by [combinedHistory] which folds
|
||||
/// these hits in alongside engine results — enabling both [localHistory]
|
||||
/// and [combinedHistory] will surface the same local URLs in two
|
||||
/// consecutive sections.
|
||||
localHistory,
|
||||
|
||||
/// Default "History" module: engine frecency results in their existing
|
||||
/// order, augmented with local content snippets where available, then
|
||||
/// padded with local-only matches at the tail. Prefer this over
|
||||
/// enabling [history] and [localHistory] separately.
|
||||
combinedHistory,
|
||||
|
||||
historyHighlights,
|
||||
topSites,
|
||||
recentHistory,
|
||||
recentArticles,
|
||||
recentTabs,
|
||||
containers;
|
||||
containers,
|
||||
frequentBangs;
|
||||
|
||||
String get label => switch (this) {
|
||||
recentSearches => 'Recent Searches',
|
||||
searchProviders => 'Search Providers',
|
||||
searchSuggestions => 'Suggestions',
|
||||
tabs => 'Tabs',
|
||||
articles => 'Articles',
|
||||
bookmarks => 'Bookmarks',
|
||||
history => 'History',
|
||||
history => 'History (engine)',
|
||||
localHistory => 'Local content',
|
||||
combinedHistory => 'History',
|
||||
historyHighlights => 'History Highlights',
|
||||
topSites => 'Top Sites',
|
||||
recentHistory => 'Recent History',
|
||||
recentArticles => 'Recent Articles',
|
||||
recentTabs => 'Recent Tabs',
|
||||
containers => 'Containers',
|
||||
frequentBangs => 'Frequent Bangs',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +81,8 @@ enum SearchModuleGroup {
|
||||
emptyState(
|
||||
key: 'EmptyStateModuleOrder',
|
||||
defaultModules: [
|
||||
SearchModuleType.recentSearches,
|
||||
SearchModuleType.frequentBangs,
|
||||
SearchModuleType.topSites,
|
||||
SearchModuleType.recentArticles,
|
||||
SearchModuleType.recentTabs,
|
||||
@@ -62,10 +94,12 @@ enum SearchModuleGroup {
|
||||
search(
|
||||
key: 'SearchModuleOrder',
|
||||
defaultModules: [
|
||||
SearchModuleType.searchProviders,
|
||||
SearchModuleType.searchSuggestions,
|
||||
SearchModuleType.tabs,
|
||||
SearchModuleType.bookmarks,
|
||||
SearchModuleType.articles,
|
||||
SearchModuleType.history,
|
||||
SearchModuleType.combinedHistory,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -76,16 +110,22 @@ enum SearchModuleGroup {
|
||||
|
||||
extension SearchModuleTypeGroup on SearchModuleType {
|
||||
SearchModuleGroup get group => switch (this) {
|
||||
SearchModuleType.recentSearches ||
|
||||
SearchModuleType.topSites ||
|
||||
SearchModuleType.recentArticles ||
|
||||
SearchModuleType.recentTabs ||
|
||||
SearchModuleType.recentHistory ||
|
||||
SearchModuleType.historyHighlights ||
|
||||
SearchModuleType.containers => SearchModuleGroup.emptyState,
|
||||
SearchModuleType.containers ||
|
||||
SearchModuleType.frequentBangs => SearchModuleGroup.emptyState,
|
||||
SearchModuleType.searchProviders ||
|
||||
SearchModuleType.searchSuggestions ||
|
||||
SearchModuleType.tabs ||
|
||||
SearchModuleType.bookmarks ||
|
||||
SearchModuleType.articles ||
|
||||
SearchModuleType.history => SearchModuleGroup.search,
|
||||
SearchModuleType.history ||
|
||||
SearchModuleType.localHistory ||
|
||||
SearchModuleType.combinedHistory => SearchModuleGroup.search,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user