Compare commits
2
Commits
main
...
issue-175-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27492127d2 | ||
|
|
9f657cd624 |
@@ -102,11 +102,7 @@ jobs:
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Build & Deploy APK to server
|
||||
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task
|
||||
# precondition fails, but we don't want that to fail the whole job — the Play
|
||||
# Store publish above already succeeded. The overall job stays green even
|
||||
# though this step shows as failed/orange in the UI.
|
||||
continue-on-error: true
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
@@ -145,12 +141,7 @@ jobs:
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Build & Deploy Linux to server
|
||||
# continue-on-error: step requires SSH_PRIVATE_KEY secret; if unset the task
|
||||
# precondition fails, but the build step that precedes this (done via Dagger)
|
||||
# already succeeded. Deployment is best-effort; a missing secret should not
|
||||
# turn the job red. The step will show as failed/orange in the UI even though
|
||||
# the overall job is green — this is intentional.
|
||||
continue-on-error: true
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
@@ -191,9 +182,7 @@ jobs:
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Generate build history and deploy website
|
||||
# continue-on-error: website publish is best-effort; a missing SSH_PRIVATE_KEY
|
||||
# should not block the overall workflow status.
|
||||
continue-on-error: true
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
|
||||
@@ -11,7 +11,6 @@ jobs:
|
||||
name: Build & Deploy Windows (Nightly)
|
||||
runs-on: windows-runner
|
||||
if: false
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -32,7 +31,6 @@ jobs:
|
||||
|
||||
- name: Set up SSH key
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
@@ -42,7 +40,6 @@ jobs:
|
||||
|
||||
- name: Deploy Windows to server
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
|
||||
@@ -4,38 +4,39 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
class UndoService extends StateNotifier<List<UndoAction>> {
|
||||
UndoService(this._ref) : super([]);
|
||||
|
||||
final Ref _ref;
|
||||
class UndoService extends Notifier<List<UndoAction>> {
|
||||
static const int _maxHistory = 10;
|
||||
|
||||
// Resolves once init() has loaded persisted history. Default to an already-
|
||||
// resolved future so operations are safe even if init() is never called.
|
||||
Future<void> _ready = Future.value();
|
||||
// Resolves once build() has loaded persisted history.
|
||||
late Future<void> _ready;
|
||||
|
||||
Future<void> init() async {
|
||||
_ready = _ref.read(undoRepositoryProvider).getHistory().then((history) {
|
||||
if (mounted) state = history;
|
||||
@override
|
||||
List<UndoAction> build() {
|
||||
_ready = ref.read(undoRepositoryProvider).getHistory().then((history) {
|
||||
if (ref.mounted) state = history;
|
||||
});
|
||||
await _ready;
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Waits for the persisted history to finish loading. Called by tests to
|
||||
/// ensure the provider is ready before asserting state.
|
||||
Future<void> init() => _ready;
|
||||
|
||||
Future<void> pushAction(UndoAction action) async {
|
||||
await _ready;
|
||||
final newList = [...state, action];
|
||||
if (newList.length > _maxHistory) {
|
||||
final removed = newList.removeAt(0);
|
||||
await _ref.read(undoRepositoryProvider).deleteAction(removed.id);
|
||||
await ref.read(undoRepositoryProvider).deleteAction(removed.id);
|
||||
}
|
||||
state = newList;
|
||||
await _ref.read(undoRepositoryProvider).saveAction(action);
|
||||
await ref.read(undoRepositoryProvider).saveAction(action);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await _ready;
|
||||
state = [];
|
||||
unawaited(_ref.read(undoRepositoryProvider).clearHistory());
|
||||
unawaited(ref.read(undoRepositoryProvider).clearHistory());
|
||||
}
|
||||
|
||||
Future<void> undo({String? actionId}) async {
|
||||
@@ -57,7 +58,7 @@ class UndoService extends StateNotifier<List<UndoAction>> {
|
||||
// happened and retry if the undo failed (e.g. after an IMAP sync reverted
|
||||
// the local change). The inverse action added below allows undoing the undo.
|
||||
|
||||
final repo = _ref.read(emailRepositoryProvider);
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
|
||||
for (final id in action.emailIds) {
|
||||
// 1. Try to cancel the original change (if not started yet).
|
||||
|
||||
+11
-12
@@ -11,6 +11,7 @@ import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/undo_repository.dart';
|
||||
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
||||
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
||||
@@ -101,7 +102,7 @@ final searchHistoryRepositoryProvider =
|
||||
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final syncLogRepositoryProvider = Provider((ref) {
|
||||
final syncLogRepositoryProvider = Provider<SyncLogRepository>((ref) {
|
||||
return SyncLogRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
@@ -181,11 +182,7 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
|
||||
});
|
||||
|
||||
final undoServiceProvider =
|
||||
StateNotifierProvider<UndoService, List<UndoAction>>((ref) {
|
||||
final service = UndoService(ref);
|
||||
unawaited(service.init());
|
||||
return service;
|
||||
});
|
||||
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
|
||||
|
||||
/// Loads email header + body and marks the email as seen.
|
||||
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
||||
@@ -194,16 +191,18 @@ final emailDetailProvider = AsyncNotifierProvider.autoDispose
|
||||
EmailDetailNotifier.new,
|
||||
);
|
||||
|
||||
class EmailDetailNotifier
|
||||
extends AutoDisposeFamilyAsyncNotifier<(Email?, EmailBody), String> {
|
||||
class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
|
||||
EmailDetailNotifier(this._emailId);
|
||||
final String _emailId;
|
||||
|
||||
@override
|
||||
Future<(Email?, EmailBody)> build(String emailId) async {
|
||||
Future<(Email?, EmailBody)> build() async {
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
final results = await Future.wait([
|
||||
repo.getEmail(emailId),
|
||||
repo.getEmailBody(emailId),
|
||||
repo.getEmail(_emailId),
|
||||
repo.getEmailBody(_emailId),
|
||||
]);
|
||||
unawaited(repo.setFlag(emailId, seen: true));
|
||||
unawaited(repo.setFlag(_emailId, seen: true));
|
||||
return (results[0] as Email?, results[1] as EmailBody);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||
|
||||
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||
import 'package:sharedinbox/core/sync/background_sync.dart';
|
||||
|
||||
@@ -43,15 +43,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
ref.listen<AsyncValue<(Email?, EmailBody)>>(
|
||||
emailDetailProvider(widget.emailId),
|
||||
(_, next) {
|
||||
final email = next.valueOrNull?.$1;
|
||||
final email = next.value?.$1;
|
||||
if (email != null && mounted) {
|
||||
setState(() => _isFlagged = email.isFlagged);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final header = detail.valueOrNull?.$1;
|
||||
final body = detail.valueOrNull?.$2;
|
||||
final header = detail.value?.$1;
|
||||
final body = detail.value?.$2;
|
||||
|
||||
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
|
||||
@@ -261,9 +261,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
|
||||
Widget _buildSyncButton(EmailRepository emailRepo) {
|
||||
final isSyncing =
|
||||
ref.watch(isSyncingProvider(widget.accountId)).valueOrNull ?? false;
|
||||
ref.watch(isSyncingProvider(widget.accountId)).value ?? false;
|
||||
final hasError =
|
||||
ref.watch(syncLastErrorProvider(widget.accountId)).valueOrNull != null;
|
||||
ref.watch(syncLastErrorProvider(widget.accountId)).value != null;
|
||||
return IconButton(
|
||||
tooltip: isSyncing
|
||||
? 'Syncing…'
|
||||
@@ -350,7 +350,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
|
||||
Widget _buildSyncErrorBanner() {
|
||||
final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId));
|
||||
final error = errorAsync.valueOrNull;
|
||||
final error = errorAsync.value;
|
||||
if (error == null || error == _dismissedError) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
+4
-4
@@ -415,10 +415,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_riverpod
|
||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
version: "3.3.1"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -891,10 +891,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod
|
||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
version: "3.2.1"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ dependencies:
|
||||
path: ^1.9.1
|
||||
|
||||
# State management
|
||||
flutter_riverpod: ^2.6.1
|
||||
flutter_riverpod: ^3.0.0
|
||||
|
||||
# Navigation
|
||||
go_router: ^17.2.3
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
@@ -19,6 +20,7 @@ import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
||||
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
||||
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
||||
@@ -473,10 +475,18 @@ Widget buildApp({
|
||||
);
|
||||
|
||||
return ProviderScope(
|
||||
// Always neutralise the ManageSieve probe so widget tests never open a
|
||||
// real socket. Tests that need to assert on probe behaviour should supply
|
||||
// their own override before this default in [overrides].
|
||||
// Defaults come first so tests can override them via [overrides].
|
||||
//
|
||||
// syncHealthProvider and syncLogRepositoryProvider are backed by Drift
|
||||
// StreamQueries. When a StreamProvider that wraps a Drift query is disposed,
|
||||
// Drift schedules a Timer.run() for cache debouncing. Flutter's test
|
||||
// framework then fails the test with "A Timer is still pending". Replacing
|
||||
// these with simple synchronous streams avoids the pending-timer assertion.
|
||||
overrides: [
|
||||
syncHealthProvider.overrideWith((ref, _) => Stream.value(null)),
|
||||
syncLogRepositoryProvider.overrideWithValue(
|
||||
const NoOpSyncLogRepository(),
|
||||
),
|
||||
...overrides,
|
||||
manageSieveProbeServiceProvider.overrideWith(
|
||||
(ref) => _NoOpManageSieveProbeService(),
|
||||
|
||||
Reference in New Issue
Block a user