Compare commits

...

7 Commits

Author SHA1 Message Date
ded7486244 Fix lints in error icon page
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:21:46 +01:00
8068205d0c Page loads of errors show an icon page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:16:04 +01:00
91a9ee5295 Proxy errors load as an empty page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:01:22 +01:00
0982565fdc Rename existing reminder channel on start
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:56:27 +01:00
957ddc7ddc Workout rows show a kind icon, not label
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:17:09 +01:00
e7ef648757 Tag manual workouts with source manual
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:01:52 +01:00
dc2799e36a Bump four app deps to newest stable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:01:51 +01:00
15 changed files with 237 additions and 30 deletions

View File

@ -0,0 +1,35 @@
/// A wordless page for an error the window itself loaded: Chrome would
/// otherwise paint its own "This page isn't working" sentences over an
/// empty error body. Only the title carries the app's name (OS-level label).
library;
import 'dart:io';
/// HTML page with only an icon — no words painted by the browser.
const String errorIconPage =
'<!doctype html><html><head><meta charset="utf-8"> '
'<title>Diet Guard</title> '
'<style>html,body{margin:0;height:100%; '
'background:#211D1B;display:flex;align-items:center; '
'justify-content:center}</style></head><body> '
'<svg width="96" height="96" viewBox="0 0 24 24" fill="none" '
'stroke="#A39892" stroke-width="2" stroke-linecap="round" '
'aria-hidden="true"><circle cx="12" cy="12" r="9"/> '
'<line x1="5.6" y1="18.4" x2="18.4" y2="5.6"/></svg></body></html>';
/// Whether [request] is the window loading a page, not the app's fetch().
bool isNavigation(HttpRequest request) =>
request.headers.value('sec-fetch-mode') == 'navigate';
/// Replaces an error answer to a page load with [errorIconPage]. Does
/// nothing for fetch() calls, successes, or a response already written.
void paintErrorIcon(HttpRequest request) {
final response = request.response;
if (!isNavigation(request) || response.statusCode < 400) return;
try {
response.headers.contentType = ContentType.html;
response.write(errorIconPage);
} on Exception {
return; // body already sent: leave it alone
}
}

View File

@ -19,6 +19,7 @@ library;
import 'dart:convert';
import 'dart:io';
import 'package:diet_guard_app/desktop/error_page.dart' show isNavigation;
import 'package:http/http.dart' as http;
/// GitHub's REST API base, which [GitHubProxy] fronts.
@ -177,8 +178,13 @@ class GitHubProxy {
}
void _passThrough(HttpRequest request, http.Response response) {
request.response.statusCode = response.statusCode;
if (isNavigation(request)) {
// Top-level navigation (window load) must not paint error JSON; the app's
// own fetch() calls never send 'navigate', so they still get the body.
return;
}
request.response
..statusCode = response.statusCode
..headers.contentType = ContentType.json
..add(response.bodyBytes);
}

View File

@ -24,6 +24,7 @@ import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/desktop/content_types.dart' as content_types;
import 'package:diet_guard_app/desktop/error_page.dart';
import 'package:diet_guard_app/desktop/github_proxy.dart';
import 'package:diet_guard_app/services/desktop_wrapper.dart';
import 'package:path/path.dart' as p;
@ -91,6 +92,7 @@ class WrapperServer {
} on Exception {
request.response.statusCode = HttpStatus.internalServerError;
}
paintErrorIcon(request);
await request.response.close();
}
}

View File

@ -24,6 +24,20 @@ class LocalNotificationsBackend implements NotificationBackend {
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
);
await plugin.initialize(settings: settings);
// Re-create the channel so Android renames it if an install still shows
// the old "Meal reminders" label.
await plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(
const AndroidNotificationChannel(
_channelId,
_channelName,
importance: Importance.high,
),
);
}
/// Requests Android 13+'s runtime `POST_NOTIFICATIONS` permission.

View File

@ -7,6 +7,7 @@ import 'package:diet_guard_app/ui/macro_kind.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:diet_guard_app/widgets/body/body_section.dart';
import 'package:diet_guard_app/widgets/body/formula_figure.dart';
import 'package:diet_guard_app/widgets/body/workout_kind_icon.dart';
import 'package:flutter/material.dart';
/// How many of the newest sessions to list.
@ -185,7 +186,22 @@ class _SessionTile extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${session.day} ${session.label}'),
Row(
children: [
Text(session.day),
const SizedBox(width: AppSpacing.sm),
Icon(
kindIcon(session.kind),
size: 16,
color: muted,
semanticLabel: session.label,
),
if (session.source == 'manual') ...[
const SizedBox(width: AppSpacing.sm),
Flexible(child: Text(session.label)),
],
],
),
Row(
children: [
Icon(

View File

@ -0,0 +1,13 @@
/// Returns the activity-type icon for [kind].
library;
import 'package:flutter/material.dart';
/// Activity kind icons used by _SessionTile (in `activity_card.dart`).
IconData kindIcon(String kind) => switch (kind) {
'run' => Icons.directions_run,
'walk' => Icons.directions_walk,
'cycle' => Icons.directions_bike,
'strength' => Icons.fitness_center,
_ => Icons.sports,
};

View File

@ -157,11 +157,11 @@ packages:
dependency: "direct main"
description:
path: crdt_sync_dart
ref: "crdt_sync_dart-v0.12.0"
resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3"
ref: "crdt_sync_dart-v0.12.1"
resolved-ref: "6641d85f0550ca60433fc8e0d13a3dafdbb664d6"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.12.0"
version: "0.12.1"
cross_file:
dependency: transitive
description:
@ -375,10 +375,10 @@ packages:
dependency: "direct main"
description:
name: fl_chart
sha256: "5ed69b8be550c6276c8f0bc25c09704fb061018ed1b1681867ad18fcf477e7b7"
sha256: "9f11b0b87a6b1843f5ca666ade4a62c08da1a6b7f9efeb91bb2c6f3fa6e31a9a"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.3.1"
flutter:
dependency: "direct main"
description: flutter
@ -544,10 +544,10 @@ packages:
dependency: "direct main"
description:
name: idb_shim
sha256: "60965b556624f7018ec26205283e32670bc78c6fe1388d4b90bdfe6b2311b87b"
sha256: "507d8f3154fa4e41165941c5aa8509c6bf155bb30f76ba25a00d1e7ba1dd64c9"
url: "https://pub.dev"
source: hosted
version: "2.11.2"
version: "2.11.3"
image:
dependency: transitive
description:
@ -824,10 +824,10 @@ packages:
dependency: transitive
description:
name: sembast
sha256: "35775a26edbdf0d891bab304929a712e103c9d9a6e2fb5ebbd8437bb3139615a"
sha256: "4f71f8c95105918fa207065429c913f38d0fb05fec9f3a745bbf4c113f5d482b"
url: "https://pub.dev"
source: hosted
version: "3.10.0"
version: "3.10.1"
share_plus:
dependency: transitive
description:
@ -941,11 +941,11 @@ packages:
dependency: "direct main"
description:
path: sync_settings_ui
ref: "sync_settings_ui-v0.6.1"
resolved-ref: ebedbf4b27fa332f7b8e6d80117d1c409bf3a7ad
ref: "sync_settings_ui-v0.6.2"
resolved-ref: "95cd3731ede686114353d32ae6c286ad1694b488"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.6.1"
version: "0.6.2"
synchronized:
dependency: transitive
description:

View File

@ -2,7 +2,7 @@ name: diet_guard_app
description: "Companion phone app for diet_guard: log meals on the go."
publish_to: 'none'
version: 1.0.147+1
version: 1.0.154+1
environment:
sdk: ^3.12.2
@ -12,7 +12,7 @@ dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.12.0
ref: crdt_sync_dart-v0.12.1
path: crdt_sync_dart
crypto: 3.0.8
cryptography: 2.9.0
@ -23,7 +23,7 @@ dependencies:
url: https://github.com/kuhyx/utils
ref: data_transfer_flutter-v0.2.1
path: data_transfer_flutter
fl_chart: 1.3.0
fl_chart: 1.3.1
flutter:
sdk: flutter
flutter_local_notifications: 23.0.0
@ -44,7 +44,7 @@ dependencies:
path: google_account_token
health: 13.3.2
http: 1.6.0
idb_shim: 2.11.2
idb_shim: 2.11.3
path: 1.9.1
path_provider: 2.1.6
permission_handler: 13.0.2
@ -57,7 +57,7 @@ dependencies:
sync_settings_ui:
git:
url: https://github.com/kuhyx/utils
ref: sync_settings_ui-v0.6.1
ref: sync_settings_ui-v0.6.2
path: sync_settings_ui
url_launcher: 6.3.3
uuid: 4.6.0

View File

@ -13,6 +13,16 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:path/path.dart' as p;
/// Removes <title> and <style> tags, strips all remaining HTML tags, and
/// returns every [A-Za-z]{2,} match — i.e. all visible words the browser would
/// paint for a top-level navigation.
List<String> paintedWords(String html) {
var noTitle = html.replaceAll(RegExp(r'<title>.*?</title>', dotAll: true), '');
var noStyle = noTitle.replaceAll(RegExp(r'<style>.*?</style>', dotAll: true), '');
var noTags = noStyle.replaceAll(RegExp(r'<[^>]*>'), '');
return RegExp(r'[A-Za-z]{2,}').allMatches(noTags).map((m) => m.group(0)!).toList();
}
void main() {
late Directory tempDir;
late WrapperServer server;
@ -69,5 +79,75 @@ void main() {
expect(response.statusCode, status);
expect(response.body, isEmpty, reason: 'nothing painted in the window');
});
test('$method $path loaded as a page shows only an icon', () async {
final request = http.Request(method, url(path))
..headers['sec-fetch-mode'] = 'navigate';
final response = await http.Response.fromStream(
await http.Client().send(request),
);
expect(response.statusCode, status);
expect(response.headers['content-type'], contains('text/html'));
expect(response.body, contains('<svg'));
expect(paintedWords(response.body), isEmpty);
});
}
group('proxy error responses', () {
late Directory tempDir;
late WrapperServer server;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('diet_guard_proxy_err_');
final webRoot = p.join(tempDir.path, 'web');
final dataDir = p.join(tempDir.path, 'data');
await Directory(webRoot).create(recursive: true);
File(p.join(webRoot, 'index.html')).writeAsStringSync('<html></html>');
await Directory(dataDir).create(recursive: true);
File(p.join(dataDir, 'documents')).writeAsStringSync('');
final tokenPath = p.join(tempDir.path, 'sync_token');
await File(tokenPath).writeAsString('t');
server = WrapperServer(
webRoot: webRoot,
dataDir: dataDir,
gitHubProxy: GitHubProxy(
tokenPath: tokenPath,
fallbackTokenPath: p.join(tempDir.path, 'fallback_token'),
httpClient: MockClient((_) async =>
http.Response('{"message": "Bad credentials"}', 401)),
),
);
await server.start(0);
});
tearDown(() async {
await server.stop();
await tempDir.delete(recursive: true);
});
Uri url(String path) => Uri.parse('http://localhost:${server.port}$path');
test('a proxy error loaded as a page shows only an icon', () async {
final request = http.Request('GET', url('/github/api/repos/o/r/contents/x'))
..headers['sec-fetch-mode'] = 'navigate';
final response = await http.Response.fromStream(
await http.Client().send(request),
);
expect(response.statusCode, HttpStatus.unauthorized);
expect(response.headers['content-type'], contains('text/html'));
expect(response.body, contains('<svg'));
expect(paintedWords(response.body), isEmpty);
});
test('the app fetch still gets the proxy error body', () async {
final response = await http.Response.fromStream(
await http.Client().send(http.Request(
'GET',
url('/github/api/repos/o/r/contents/x'),
)),
);
expect(response.statusCode, HttpStatus.unauthorized);
expect(response.body, contains('Bad credentials'));
});
});
}

View File

@ -1,5 +1,6 @@
// Guard: the Body screen, every tile sheet and the activity-method chooser
// paint no words (only numbers, unit symbols, MET and workout labels).
// Workout labels are content only for manual workouts (source == 'manual').
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@ -12,10 +13,9 @@ import 'screens/body_screen_support.dart';
/// proper names of the BMI / ideal-weight / BMR formulas that label their
/// figures (DOUBT rows in keep.md: kept from the first pilot, outside the
/// activity sheet). Activity formulas (ACSM, Per-km) are NOT here: that sheet
/// and its chooser show glyphs only.
/// and its chooser show glyphs only. Only manual workouts carry human content.
const Set<String> _workouts = {
'Evening run',
'StrongLifts A',
'walk to the shop',
'Harris-Benedict (revised 1984)',
'Peterson (BMI 22)',
'Mifflin-St Jeor',
@ -41,8 +41,8 @@ void main() {
expect(wordsIn('70.0 kg'), isEmpty);
expect(wordsIn('265 MET'), isEmpty);
expect(wordsIn('Measured (ACSM)'), isNotEmpty);
expect(wordsIn('Evening run 4 km', content: _workouts), ['km']);
expect(wordsIn('Evening run', content: _workouts), isEmpty);
expect(wordsIn('walk to the shop 4 km', content: _workouts), ['km']);
expect(wordsIn('walk to the shop', content: _workouts), isEmpty);
});
testWidgets('canary: a painted word fails the guard', (tester) async {

View File

@ -70,16 +70,24 @@ Future<void> seedPerson(MemoryDocStore store, {Object? goal}) async {
'kind': 'run',
'min': 60,
'km': 10,
'label': 'Evening run',
'label': 'Running 10.0 km',
'src': 'runnerup',
},
{
'day': yesterdayKey(),
'kind': 'strength',
'min': 80,
'label': 'StrongLifts A',
'label': 'StrongLifts workout A (80 min, all succeeded)',
'src': 'screen-locker',
},
{
'day': yesterdayKey(),
'kind': 'walk',
'min': 30,
'km': 2,
'label': 'walk to the shop',
'src': 'manual',
},
],
},
});

View File

@ -133,8 +133,15 @@ void main() {
byIconLabel('Mean daily exercise, last 1 full days:'),
findsOneWidget,
);
expect(find.textContaining('Evening run'), findsOneWidget);
expect(find.textContaining('StrongLifts A'), findsOneWidget);
expect(
byIconLabel('Running 10.0 km'),
findsOneWidget,
);
expect(
byIconLabel('StrongLifts workout A (80 min, all succeeded)'),
findsOneWidget,
);
expect(find.textContaining('walk to the shop'), findsOneWidget);
expect(byIconLabel('Connect Health Connect steps'), findsNothing);
});

View File

@ -34,6 +34,23 @@ void main() {
expect(log.where((c) => c.method == 'initialize'), hasLength(1));
});
test('init renames the reminder channel to the app name', () async {
final log = installFakeAndroidNotifications();
NotificationService.resetForTesting(
backend: LocalNotificationsBackend(FlutterLocalNotificationsPlugin()),
);
await NotificationService.init();
final createCall = log.firstWhere(
(c) => c.method == 'createNotificationChannel',
orElse: () => throw StateError('no createNotificationChannel call logged'),
);
final args = createCall.arguments as Map;
expect(args['id'], reminderChannelId);
expect(args['name'], 'DietGuard');
});
test('requestPermission delegates to the Android implementation', () async {
installFakeAndroidNotifications();
NotificationService.resetForTesting(

View File

@ -100,11 +100,16 @@ def _clock_window(day: str, item: dict[str, object]) -> tuple[str | None, str |
def manual_session(
day: str, item: dict[str, object], minutes: float, label: str
) -> Session:
"""The :class:`Session` one manual workout stands for."""
"""The :class:`Session` one manual workout stands for.
Source is "manual" so the phone's activity sheet can paint the human-typed
label (``source == "manual"`` in Dart). Verified screen-locker workouts keep
source "screen-locker".
"""
text = _text(item)
kind = _kind(item, text)
km = _typed_km(item)
if km is None and kind in {"run", "walk"}:
km = _text_km(text)
start, end = _clock_window(day, item)
return Session(day, kind, minutes, km, label, "screen-locker", start, end)
return Session(day, kind, minutes, km, label, "manual", start, end)

View File

@ -47,3 +47,7 @@ class TestManualParse:
assert start is not None
assert end is not None
assert logged_window("never", 60) == (None, None)
def test_manual_session_source_is_manual(self) -> None:
session = manual_session("2026-09-20", {}, 45.0, "walk to work")
assert session.source == "manual"