Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ What changed in each version of the app, newest first. The format is
- Exchange-rate estimates only use a rate from within a week of the expense's day, and the server keeps a full year of rates to draw on.
- Two people editing the same expense at once no longer quietly lose one edit: whichever arrives second is set aside, with the newer version shown, to be applied again on top of it.
- Saving something syncs that group alone, and all your groups are read in one request, so syncing uses far less data and battery.
- OpenSplit opens straight onto what is saved on this device, behind one launch screen, with a thin bar while it checks for changes.
- When you are offline, a cloud icon at the top of the screen says so.
- Moving between Groups, Account and Settings fades from one to the next.
- The Android launch screen shows the logo without a tile behind it.
- Text on screen is no longer selectable, as in other apps. Invite links can still be copied.

### Fixed
- Settling up with somebody and then removing them while offline no longer leaves the removal refused once you are back online.
Expand All @@ -52,6 +57,9 @@ What changed in each version of the app, newest first. The format is
- Nobody can add a new expense that charges somebody who has left a group.
- An expense split among 26 or more people now saves; before, the server refused it and the app kept retrying.
- In Settle up, making the person being paid the payer now clears "Who is being paid" on screen too, not just behind it.
- Swiping back with the date or time picker open closes the picker, not the screen behind it.
- The Account page opens with your name and UPI ID already showing, instead of filling them in a moment later.
- Syncing tries again as soon as you are back online, rather than waiting for its next turn.

## [2.0.0] - 2026-10-01
### Changed
Expand Down
2 changes: 0 additions & 2 deletions android/app/src/main/res/values-night-v31/styles.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#131318</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#1F1F25</item>
</style>

<!-- Same as the pre-31 NormalTheme, and stated again because this file
shadows it on Android 12 and up. The generator leaves
?android:colorBackground here, which is the platform's white and is
Expand Down
1 change: 0 additions & 1 deletion android/app/src/main/res/values-night/styles.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>

<!-- The window behind the Flutter UI once it is running. Visible during a
rotation or a resize, so it is the app's surface rather than the
platform's ?android:colorBackground, which is plain white. -->
Expand Down
2 changes: 0 additions & 2 deletions android/app/src/main/res/values-v31/styles.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#FCF8FF</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#E3DFFF</item>
</style>

<!-- Same as the pre-31 NormalTheme, and stated again because this file
shadows it on Android 12 and up. The generator leaves
?android:colorBackground here, which is the platform's white and is
Expand Down
1 change: 0 additions & 1 deletion android/app/src/main/res/values/styles.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>

<!-- The window behind the Flutter UI once it is running. Visible during a
rotation or a resize, so it is the app's surface rather than the
platform's ?android:colorBackground, which is plain white. -->
Expand Down
16 changes: 9 additions & 7 deletions flutter_native_splash.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Regenerate with: dart run flutter_native_splash:create
#
# Colours here are ColorScheme values written out as literals, because none of
# this runs Dart — the same duplication the web loading skeleton has, and pinned
# the same way, by a test.
# this runs Dart — the same duplication the web splash has, and pinned the same
# way, by a test.
#
# #FCF8FF is ColorScheme.surface in the light scheme, #131318 in the dark.
# The brand kit quotes the light one as #FBF8FF; this is what
Expand All @@ -17,19 +17,21 @@ flutter_native_splash:
# Android 12 replaced the full-bleed splash image with an icon in a masked
# circle. splash-icon.png is 1152px with the mark at 640px specifically to
# survive that crop.
#
# No icon_background_color: the mark sits directly on the surface, as it does
# below Android 12 and on the web. A background is drawn in the launcher's
# icon shape, which varies by manufacturer, and in a container colour too
# close to the surface to read as deliberate.
android_12:
image: assets/splash/splash-icon.png
image_dark: assets/splash/splash-icon-dark.png
icon_background_color: "#E3DFFF"
icon_background_color_dark: "#1F1F25"

android: true
android_gravity: center

# No iOS target in this repo.
ios: false

# The web has its own loading state, written by hand in web/index.html: a
# skeleton of the screen that is about to appear, drawn in the theme's own
# colours. This generator would inject a centred logo above it and fight it.
# The web's splash is written by hand in web/index.html, as inline SVG so it
# paints on the first round trip and fades out on Flutter's first frame.
web: false
14 changes: 13 additions & 1 deletion lib/application/sync_coordinator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'dart:math' as math;

import 'package:flutter/foundation.dart';

import '../data/sync/api_client.dart';
import '../data/sync/sync_engine.dart';

@immutable
Expand All @@ -25,6 +26,10 @@ class SyncStatus {
final DateTime? retryAt;

Object? get error => lastReport?.error;

/// Whether the last attempt failed because no server could be reached,
/// which is the ordinary state of an offline device rather than a fault.
bool get isOffline => isUnreachable(error);
}

/// Decides when to sync, runs one sync at a time, and reports status.
Expand Down Expand Up @@ -72,7 +77,14 @@ class SyncCoordinator extends ChangeNotifier {
_subscriptions
..add(
_online.listen((isOnline) {
if (isOnline) resumed();
if (!isOnline) return;
// A failed attempt is retried as soon as the network is back, not
// when its back-off ends or the usual gap has passed.
if (_status.error == null) {
resumed();
} else {
unawaited(syncAll());
}
}),
)
..add(_writes.listen((_) => _writeQueued()));
Expand Down
26 changes: 25 additions & 1 deletion lib/data/sync/api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ api.OpensplitApi buildApiClient({

/// A request the server refused, or that never got an answer.
class ApiFailure implements Exception {
const ApiFailure(this.message, {required this.retry, this.code});
const ApiFailure(
this.message, {
required this.retry,
this.code,
this.unreachable = false,
});

/// Reads the `{error: {code, message, retry}}` envelope off a failed request.
factory ApiFailure.from(DioException error) {
Expand All @@ -54,6 +59,7 @@ class ApiFailure implements Exception {
retry: status == null || status >= 500
? api.Retry.transient
: api.Retry.permanent,
unreachable: _neverAnswered(error),
);
}

Expand All @@ -63,6 +69,10 @@ class ApiFailure implements Exception {
/// Null when no server answered in the expected shape.
final api.ErrorCode? code;

/// Whether the request never reached a server that answered: the device is
/// offline, or the server is out of reach from where it is.
final bool unreachable;

static api.ErrorError? _decode(Map<String, dynamic> body) {
try {
return api.Error.fromJson(body).error;
Expand Down Expand Up @@ -99,3 +109,17 @@ Future<void> send(Future<Response<void>> request) async {
throw ApiFailure.from(error);
}
}

/// Whether [error] means no server answered at all, as opposed to a server
/// that answered and refused.
///
/// Judged by the request itself rather than by the device's network
/// interfaces, which report a captive portal or a dead Wi-Fi as connected.
bool isUnreachable(Object? error) => switch (error) {
ApiFailure(:final unreachable) => unreachable,
DioException() => _neverAnswered(error),
_ => false,
};

bool _neverAnswered(DioException error) =>
error.response == null && error.type != DioExceptionType.cancel;
4 changes: 0 additions & 4 deletions lib/data/web/boot_hint.dart

This file was deleted.

4 changes: 0 additions & 4 deletions lib/data/web/boot_hint_stub.dart

This file was deleted.

33 changes: 0 additions & 33 deletions lib/data/web/boot_hint_web.dart

This file was deleted.

5 changes: 5 additions & 0 deletions lib/data/web/launch_signal.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// Telling the page outside Flutter that the app is on screen.
library;

export 'launch_signal_stub.dart'
if (dart.library.js_interop) 'launch_signal_web.dart';
2 changes: 2 additions & 0 deletions lib/data/web/launch_signal_stub.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/// Android's splash is the platform's, and goes by itself on the first frame.
void announceLaunchShown() {}
13 changes: 13 additions & 0 deletions lib/data/web/launch_signal_web.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import 'package:web/web.dart' as web;

/// The event `web/index.html` waits for before fading out its splash.
const _launchShown = 'opensplit-launch-shown';

/// Tells `web/index.html` that the first real frame is on screen.
///
/// Flutter's own `flutter-first-frame` event fires after the first frame is
/// built even while that frame is deferred, so it would take the splash down
/// onto a blank page while the app is still reading saved data.
void announceLaunchShown() {
web.window.dispatchEvent(web.Event(_launchShown));
}
6 changes: 6 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,18 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'application/backend_providers.dart';
import 'application/preferences_providers.dart';
import 'config.dart';
import 'data/web/launch_signal.dart';
import 'data/auth/session_store.dart';
import 'presentation/app.dart';
import 'presentation/launch_hold.dart';

Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

// The splash stays up while the first screen reads what is saved, so a launch
// is one wait that ends on real content.
LaunchHold.begin(onShown: announceLaunchShown);

// google_fonts falls back to downloading a face it cannot find in the bundle.
GoogleFonts.config.allowRuntimeFetching = false;

Expand Down
13 changes: 7 additions & 6 deletions lib/presentation/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import 'package:in_app_update/in_app_update.dart';
import '../application/local_providers.dart';
import '../application/preferences_providers.dart';
import '../application/push_providers.dart';
import '../application/ledger_providers.dart';
import '../application/router_provider.dart';
import '../application/session_providers.dart';
import '../application/sync_providers.dart';
import '../data/platform/app_update_service.dart';
import '../data/web/boot_hint.dart';
import '../data/web/release_updates.dart';
import '../domain/auth_service.dart';
import '../l10n/app_localizations.dart';
Expand Down Expand Up @@ -66,11 +66,12 @@ class _OpenSplitAppState extends ConsumerState<OpenSplitApp> {
// Built for each account, which starts its automatic sync.
ref.listenManual(syncControllerProvider, (_, _) {}, fireImmediately: true);

ref.listenManual(signedInProvider, (_, signedIn) {
// Leaves a note for the next cold start, so the web loader draws the
// layout this session will actually land on.
recordSignedIn(signedIn);
}, fireImmediately: true);
// Your own profile is app state, so it is read at launch and kept current:
// the Account destination then has it on the frame it is first built,
// instead of drawing "no name yet" until it arrives. Building that branch
// early would not do it, because Riverpod pauses providers that only
// offstage widgets listen to.
ref.listenManual(myProfileProvider, (_, _) {});

// A Google flow that left the page finishes here, on the one launch that is
// a return from it.
Expand Down
Loading