mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 22:27:10 +01:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ded7486244 | |||
| 8068205d0c | |||
| 91a9ee5295 | |||
| 0982565fdc | |||
| 957ddc7ddc | |||
| e7ef648757 | |||
| dc2799e36a |
35
app/lib/desktop/error_page.dart
Normal file
35
app/lib/desktop/error_page.dart
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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(
|
||||
|
||||
13
app/lib/widgets/body/workout_kind_icon.dart
Normal file
13
app/lib/widgets/body/workout_kind_icon.dart
Normal 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,
|
||||
};
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user