Commit afeb18a

derdilla <82763757+derdilla@users.noreply.github.com>
2025-05-04 09:17:39
Allow creating detailed logs in production apps (#559)
* Implement screen to view logs and manage verbose logging * Implement input logging relevant to #558, #552 * Implement export logging relevant to #556 * Fix add entry note prefill * Improve logging screen * Update todos
1 parent a7e1470
Changed files (8)
app
lib
test
features
app/lib/data_util/entry_context.dart
@@ -9,15 +9,19 @@ import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart' hide ProviderNotFoundException;
 import 'package:blood_pressure_app/l10n/app_localizations.dart';
 import 'package:health_data_store/health_data_store.dart';
+import 'package:logging/logging.dart';
 import 'package:provider/provider.dart';
 
 /// Allow high level operations on the repositories in context.
 extension EntryUtils on BuildContext {
+  Logger get _logger => Logger('BPM[context.EntryUtils]');
+
   /// Open the [AddEntryDialogue] and save received entries.
   ///
   /// Follows [ExportSettings.exportAfterEveryEntry]. When [initial] is not null
   /// the dialoge will be opened in edit mode.
   Future<void> createEntry([AddEntryFormValue? initial]) async {
+    _logger.finer('createEntry($initial)');
     try {
       final recordRepo = RepositoryProvider.of<BloodPressureRepository>(this);
       final noteRepo = RepositoryProvider.of<NoteRepository>(this);
@@ -29,6 +33,7 @@ extension EntryUtils on BuildContext {
         RepositoryProvider.of<MedicineRepository>(this),
         initial,
       );
+      _logger.finest('received $entry from dialog');
       if (entry != null) {
         if (initial?.record != null) await recordRepo.remove(initial!.record!);
         if (initial?.note != null) await noteRepo.remove(initial!.note!);
@@ -40,8 +45,19 @@ extension EntryUtils on BuildContext {
         if (entry.intake != null) await intakeRepo.add(entry.intake!);
         if(entry.weight != null) await weightRepo.add(entry.weight!);
 
+        /*
+        read<IntervalStoreManager>().mainPage.setToMostRecentInterval();
+        read<IntervalStoreManager>().statsPage.setToMostRecentInterval();
+        read<IntervalStoreManager>().exportPage.setToMostRecentInterval();*/
+
+        _logger.finest('mounted=$mounted');
+        if (!mounted) {
+          _logger.warning('Context no longer mounted');
+          return;
+        }
+
+        log.info(read<IntervalStoreManager>());
         if (mounted && exportSettings.exportAfterEveryEntry) {
-          read<IntervalStoreManager>().exportPage.setToMostRecentInterval();
           performExport(this);
         }
       }
app/lib/features/export_import/export_button.dart
@@ -3,6 +3,7 @@ import 'dart:convert';
 import 'dart:io';
 import 'dart:typed_data';
 
+import 'package:blood_pressure_app/logging.dart';
 import 'package:blood_pressure_app/model/export_import/csv_converter.dart';
 import 'package:blood_pressure_app/model/export_import/pdf_converter.dart';
 import 'package:blood_pressure_app/model/storage/export_columns_store.dart';
@@ -17,6 +18,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 import 'package:blood_pressure_app/l10n/app_localizations.dart';
 import 'package:health_data_store/health_data_store.dart';
+import 'package:logging/logging.dart';
 import 'package:path/path.dart';
 import 'package:persistent_user_dir_access_android/persistent_user_dir_access_android.dart';
 import 'package:provider/provider.dart';
@@ -35,10 +37,14 @@ class ExportButton extends StatelessWidget {
   );
 }
 
+Logger _logger = Logger('BPM[export_button]');
+
 /// Perform a full export according to the configuration in [context].
 void performExport(BuildContext context) async { // TODO: extract
+  _logger.finer('performExport - mounted=${context.mounted}');
   final localizations = AppLocalizations.of(context);
   final exportSettings = Provider.of<ExportSettings>(context, listen: false);
+  _logger.fine('performExport - exportSettings=${exportSettings.toJson()}');
   final filename = 'blood_press_${DateTime.now().toIso8601String()}';
   switch (exportSettings.exportFormat) {
     case ExportFormat.db:
@@ -55,10 +61,19 @@ void performExport(BuildContext context) async { // TODO: extract
         exportColumnsManager,
         await RepositoryProvider.of<MedicineRepository>(context).getAll(),
       );
-      if (!context.mounted) return;
+      if (!context.mounted) {
+        _logger.warning('performExport - No longer mounted: stopping export');
+        return;
+      }
       final csvString = csvConverter.create(await _getEntries(context));
+      _logger.fine('performExport - Created csvString=$csvString');
       final data = Uint8List.fromList(utf8.encode(csvString));
-      if (context.mounted) await _exportData(context, data, '$filename.csv', 'text/csv');
+      if (context.mounted) {
+        _logger.finer('performExport - Calling _exportData');
+        await _exportData(context, data, '$filename.csv', 'text/csv');
+      } else  {
+        _logger.warning('performExport - No longer mounted: stopping export');
+      }
       break;
     case ExportFormat.pdf:
       final pdfConverter = PdfConverter(
@@ -75,6 +90,7 @@ void performExport(BuildContext context) async { // TODO: extract
 /// Get the records that should be exported (oldest first).
 Future<List<(DateTime, BloodPressureRecord, Note, List<MedicineIntake>, Weight?)>> _getEntries(BuildContext context) async {
   final range = Provider.of<IntervalStoreManager>(context, listen: false).exportPage.currentRange;
+  _logger.fine('_getEntries - range=$range');
   final bpRepo = RepositoryProvider.of<BloodPressureRepository>(context);
   final noteRepo = RepositoryProvider.of<NoteRepository>(context);
   final intakeRepo = RepositoryProvider.of<MedicineIntakeRepository>(context);
@@ -85,7 +101,11 @@ Future<List<(DateTime, BloodPressureRecord, Note, List<MedicineIntake>, Weight?)
   final intakes = await intakeRepo.get(range);
   final weights = await weightRepo.get(range);
 
+  _logger.finest('_getEntries - range=$range');
+
   final entries = FullEntryList.merged(records, notes, intakes);
+  _logger.fine('_getEntries - merged ${records.length} records, ${notes.length}'
+      ' notes, and ${intakes.length} intakes to ${entries.length} entries');
 
   final entriesWithWeight = entries
       .map((e) => (e.time, e.recordObj, e.noteObj, e.intakes, weights.firstWhereOrNull((w) => e.time == w.time)?.weight))
@@ -94,6 +114,9 @@ Future<List<(DateTime, BloodPressureRecord, Note, List<MedicineIntake>, Weight?)
     entriesWithWeight.add((e.time, BloodPressureRecord(time: e.time), Note(time: e.time), [], e.weight));
   }
 
+  _logger.fine('_getEntries - added ${weights.length} weights to get'
+      ' ${entries.length} entries');
+
   entriesWithWeight.sort((a, b) => a.$1.compareTo(b.$1));
   return entriesWithWeight;
 }
@@ -102,12 +125,14 @@ Future<List<(DateTime, BloodPressureRecord, Note, List<MedicineIntake>, Weight?)
 Future<void> _exportData(BuildContext context, Uint8List data, String fullFileName, String mimeType) async {
   final settings = Provider.of<ExportSettings>(context, listen: false);
   if (settings.defaultExportDir.isEmpty || !Platform.isAndroid) {
+    _logger.fine('_exportData - Saving file using FilePicker');
     await FilePicker.platform.saveFile(
       type: FileType.any, // mimeType
       fileName: fullFileName,
       bytes: data,
     );
   } else {
+    _logger.fine('_exportData - Saving file using PersistentUserDirAccessAndroid');
     const userDir = PersistentUserDirAccessAndroid();
     await userDir.writeFile(settings.defaultExportDir, fullFileName, mimeType, data);
   }
app/lib/features/input/forms/add_entry_form.dart
@@ -41,7 +41,8 @@ class AddEntryForm extends FormBase<AddEntryFormValue> with TypeLogger {
 }
 
 /// State of primary form to enter all types of entries.
-class AddEntryFormState extends FormStateBase<AddEntryFormValue, AddEntryForm> {
+class AddEntryFormState extends FormStateBase<AddEntryFormValue, AddEntryForm>
+    with TypeLogger {
   final _timeForm = GlobalKey<DateTimeFormState>();
   final _noteForm = GlobalKey<NoteFormState>();
   final _bpForm = GlobalKey<BloodPressureFormState>();
@@ -59,6 +60,7 @@ class AddEntryFormState extends FormStateBase<AddEntryFormValue, AddEntryForm> {
   @override
   void initState() {
     super.initState();
+    logger.finer('Initializing with ${widget.initialValue}');
     if (widget.initialValue != null) {
       _lastSavedPressure = widget.initialValue?.record;
       _lastSavedWeight = widget.initialValue?.weight;
@@ -95,16 +97,30 @@ class AddEntryFormState extends FormStateBase<AddEntryFormValue, AddEntryForm> {
   }
 
   @override
-  bool validate() => !context.read<Settings>().validateInputs
-    || (_timeForm.currentState?.validate() ?? false)
-    && (_noteForm.currentState?.validate() ?? false)
+  bool validate() {
+    final timeFormValidation = _timeForm.currentState?.validate();
+    final noteFormValidation = _noteForm.currentState?.validate();
+    final bpFormValidation = _bpForm.currentState?.validate();
+    final weightFormValidation = _weightForm.currentState?.validate();
+    final intakeFormValidation = _intakeForm.currentState?.validate();
+    logger.fine('validating...');
+    logger.finest('time: $timeFormValidation');
+    logger.finest('note: $noteFormValidation');
+    logger.finest('bp: $bpFormValidation');
+    logger.finest('weight: $weightFormValidation');
+    logger.finest('intake: $intakeFormValidation');
+    return !context.read<Settings>().validateInputs
+    || (timeFormValidation ?? false)
+    && (noteFormValidation ?? false)
     // the following become null when unopened
-    && (_bpForm.currentState?.validate() ?? true)
-    && (_weightForm.currentState?.validate() ?? true)
-    && (_intakeForm.currentState?.validate() ?? true);
+    && (bpFormValidation ?? true)
+    && (weightFormValidation ?? true)
+    && (intakeFormValidation ?? true);
+  }
 
   @override
   AddEntryFormValue? save() {
+    logger.fine('Calling save');
     if (!validate()) return null;
     final time = _timeForm.currentState!.save()!;
     Note? note;
@@ -138,11 +154,13 @@ class AddEntryFormState extends FormStateBase<AddEntryFormValue, AddEntryForm> {
         dosis: intakeFormValue.$2,
       );
     }
+    logger.finer('Saving values: $note, $record, $weight, $intake');
 
     if (note == null
       && record == null
       && weight == null
       && intake == null) {
+      logger.fine('note, record, weight, and intake are null: returning null');
       return null;
     }
     return (
@@ -159,6 +177,7 @@ class AddEntryFormState extends FormStateBase<AddEntryFormValue, AddEntryForm> {
 
   @override
   void fillForm(AddEntryFormValue? value) {
+    logger.finer('fillForm($value)');
     _lastSavedPressure = value?.record;
     _lastSavedWeight = value?.weight;
     _lastSavedIntake = value?.intake;
@@ -260,7 +279,8 @@ class AddEntryFormState extends FormStateBase<AddEntryFormValue, AddEntryForm> {
         NoteForm(
           key: _noteForm,
           initialValue: (){
-            if (widget.initialValue?.note?.note == null) return null;
+            logger.fine('NoteForm.initialValue: ${widget.initialValue?.note}');
+            if (widget.initialValue?.note == null) return null;
             final note = widget.initialValue!.note!;
             final color = note.color == null ? null : Color(note.color!);
             return (note.note, color);
@@ -287,7 +307,7 @@ extension AddEntryFormValueCompat on FullEntry {
     assert(intakes.length <= 1);
     return (
       timestamp: time,
-      note: (note != null && color == null) ? null : noteObj,
+      note: (note == null && color == null) ? null : noteObj,
       record: (sys == null && dia == null && pul == null) ? null : recordObj,
       intake: intakes.firstOrNull,
       weight: null,
app/lib/features/input/add_entry_dialogue.dart
@@ -2,6 +2,7 @@ import 'dart:async';
 
 import 'package:blood_pressure_app/components/fullscreen_dialoge.dart';
 import 'package:blood_pressure_app/features/input/forms/add_entry_form.dart';
+import 'package:blood_pressure_app/logging.dart';
 import 'package:blood_pressure_app/model/storage/storage.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
@@ -33,12 +34,13 @@ class AddEntryDialogue extends StatefulWidget {
   State<AddEntryDialogue> createState() => _AddEntryDialogueState();
 }
 
-class _AddEntryDialogueState extends State<AddEntryDialogue> {
+class _AddEntryDialogueState extends State<AddEntryDialogue> with TypeLogger {
   final formKey = GlobalKey<AddEntryFormState>();
 
   void _onSavePressed() {
     if (formKey.currentState?.validate() ?? false) {
       final AddEntryFormValue? result = formKey.currentState?.save();
+      logger.finer('Returning result: $result');
       Navigator.pop(context, result);
     } else {
       // Errors are displayed below their specific widgets
app/lib/features/settings/version_screen.dart
@@ -1,13 +1,23 @@
 import 'package:blood_pressure_app/data_util/consistent_future_builder.dart';
+import 'package:blood_pressure_app/features/settings/tiles/titled_column.dart';
+import 'package:blood_pressure_app/logging.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:blood_pressure_app/l10n/app_localizations.dart';
+import 'package:flutter_blue_plus/flutter_blue_plus.dart';
+import 'package:logging/logging.dart';
 import 'package:package_info_plus/package_info_plus.dart';
 
-class VersionScreen extends StatelessWidget {
-
+/// Screen that shows app version and debug options.
+class VersionScreen extends StatefulWidget {
+  /// Screen that shows app version and debug options.
   const VersionScreen({super.key});
 
+  @override
+  State<VersionScreen> createState() => _VersionScreenState();
+}
+
+class _VersionScreenState extends State<VersionScreen> with TypeLogger {
   @override
   Widget build(BuildContext context) {
     final localizations = AppLocalizations.of(context)!;
@@ -18,7 +28,7 @@ class VersionScreen extends StatelessWidget {
           IconButton(
             onPressed: () async {
               final packageInfo = await PackageInfo.fromPlatform();
-              Clipboard.setData(ClipboardData(
+              await Clipboard.setData(ClipboardData(
                   text: 'Blood pressure monitor\n'
                       '${packageInfo.packageName}\n'
                       '${packageInfo.version} - ${packageInfo.buildNumber}',
@@ -30,20 +40,98 @@ class VersionScreen extends StatelessWidget {
         ],
         backgroundColor: Theme.of(context).primaryColor,
       ),
-      body: Container(
+      body: Padding(
         padding: const EdgeInsets.all(10.0),
-        child: Center(
-          child: ConsistentFutureBuilder<PackageInfo>(
-            future: PackageInfo.fromPlatform(),
-            onData: (context, packageInfo) => Column(
-                crossAxisAlignment: CrossAxisAlignment.start,
-                children: [
-                  Text(localizations.packageNameOf(packageInfo.packageName)),
-                  Text(localizations.versionOf(packageInfo.version)),
-                  Text(localizations.buildNumberOf(packageInfo.buildNumber)),
-                ],
+        child: ListView(
+          children: [
+            // Debug info
+            ConsistentFutureBuilder<PackageInfo>(
+              future: PackageInfo.fromPlatform(),
+              onData: (context, packageInfo) => Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    Text(localizations.packageNameOf(packageInfo.packageName)),
+                    Text(localizations.versionOf(packageInfo.version)),
+                    Text(localizations.buildNumberOf(packageInfo.buildNumber)),
+                  ],
+                ),
+            ),
+            // Logs
+            SwitchListTile(
+              // Would not be used by regular users so no need to translate
+              title: Text('Enable ultra-verbose logging until app restart'),
+              subtitle: Text('This can help to track down hard to reproduce bugs'),
+              value: Log.isVerbose,
+              onChanged: (v) =>  setState(() => Log.setVerbose(v)),
+            ),
+            ListTile(
+              title: Text('Logs:'),
+              trailing: Icon(Icons.copy),
+              onTap: () async {
+                await Clipboard.setData(ClipboardData(
+                  text: Log.logs
+                    .map((e) => '${e.level.name} - ${e.time.toIso8601String()}||'
+                      '${e.loggerName}||"${e.message}"||{${e.stackTrace}}\n')
+                    .fold('', (res, e) => res + e),
+                ));
+                if(context.mounted){
+                  ScaffoldMessenger.of(context).showSnackBar(SnackBar(
+                    content: Text('Logs copied to clipboard'),
+                  ));
+                }
+              },
+            ),
+            SizedBox(
+               height: 600,
+              child: ListView.builder(
+                shrinkWrap: true,
+                itemCount: Log.logs.length,
+                itemBuilder: (context, idx) {
+                  final record = Log.logs[Log.logs.length - idx - 1];
+                  return ExpansionTile(
+                    title: Wrap(
+                      spacing: 4.0,
+                      crossAxisAlignment: WrapCrossAlignment.center,
+                      //mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                      children: [
+                        Chip(
+                          // FIXME: doesn't work in light mode
+                          backgroundColor: switch(record.level.value) {
+                            <= 500 => Colors.transparent,
+                            <= 800 => Colors.grey.shade800,
+                            <= 900 => Colors.deepOrange,
+                            <= 1000 => Colors.red,
+                            int() => Colors.red.shade900,
+                          },
+                          label: Text(record.level.name),
+                        ),
+                        Text(record.loggerName),
+                      ],
+                    ),
+                    subtitle: Text('Timestamp: ${record.time.hour}:${record.time.minute}.${record.time.second}'),
+                    children: [
+                      Text(record.message),
+                      if (record.stackTrace != null)
+                        Text(record.stackTrace.toString()),
+                    ],
+                  );
+                },
               ),
-          ),
+            ),
+            ListTile(
+              title: Text('Test log messages'),
+              trailing: Icon(Icons.chevron_right),
+              onTap: () {
+                logger.finest('test finest');
+                logger.finer('test finer');
+                logger.fine('test fine');
+                logger.info('test info');
+                logger.warning('test warning');
+                logger.severe('test severe');
+                logger.shout('test shout');
+              },
+            )
+          ],
         ),
       ),
     );
app/lib/model/export_import/csv_converter.dart
@@ -1,3 +1,4 @@
+import 'package:blood_pressure_app/logging.dart';
 import 'package:blood_pressure_app/model/export_import/column.dart';
 import 'package:blood_pressure_app/model/export_import/import_field_type.dart' show RowDataFieldType;
 import 'package:blood_pressure_app/model/export_import/record_parsing_result.dart';
@@ -8,9 +9,15 @@ import 'package:csv/csv.dart';
 import 'package:health_data_store/health_data_store.dart';
 
 /// Utility class to convert between csv strings and [BloodPressureRecord]s.
-class CsvConverter {
+class CsvConverter with TypeLogger {
   /// Create converter between csv strings and [BloodPressureRecord] values that respects settings.
-  CsvConverter(this.settings, this.availableColumns, this.availableMedicines);
+  CsvConverter(this.settings, this.availableColumns, this.availableMedicines) {
+    logger.fine('Creating CsvConverter with '
+        'settings=${settings.toJson()}, '
+        'availableColumns=$availableColumns, ',
+        'availableMedicines=$availableMedicines'
+    );
+  }
 
   /// Settings that apply for ex- and import.
   final CsvExportSettings settings;
app/lib/logging.dart
@@ -18,9 +18,18 @@ mixin TypeLogger {
 ///
 /// Also contains some logging configuration logic
 class Log {
-  /// Whether logging is enabled
+  static final _verboseLevel = Level.ALL;
+  static final _normalLevel = Level.WARNING;
+
+  /// Logs recorded this session.
+  static final logs = <LogRecord>[];
+
+  /// Whether debug logging is enabled.
   static final enabled = kDebugMode && !isTestingEnvironment;
 
+  /// Whether verbose logging is activated.
+  static bool get isVerbose => Logger.root.level == Level.ALL;
+
   /// Format a log record
   static String format(LogRecord record) {
     final loggerName = record.loggerName == 'BloodPressureMonitor' ? null : record.loggerName;
@@ -32,9 +41,20 @@ class Log {
 
   /// Register the apps logging config with [Logger].
   static void setup() {
+    Logger.root.onRecord.listen(logs.add);
     if (Log.enabled) {
-      Logger.root.level = Level.ALL;
+      Logger.root.level = _verboseLevel;
       Logger.root.onRecord.listen((record) => debugPrint(Log.format(record)));
+    } else {
+      Logger.root.level = _normalLevel;
     }
   }
+
+  /// Set ultra verbose(true) or normal logging(false).
+  static void setVerbose(bool isVerbose) {
+    Logger.root.level = isVerbose
+        ? _verboseLevel
+        : _normalLevel;
+    Logger.root.info('Verbose logging set to $isVerbose');
+  }
 }
app/test/features/input/forms/add_entry_form_test.dart
@@ -17,6 +17,7 @@ import 'package:health_data_store/health_data_store.dart';
 import 'package:intl/intl.dart';
 
 import '../../../model/analyzer_test.dart';
+import '../../../model/export_import/record_formatter_test.dart';
 import '../../../util.dart';
 import '../../measurement_list/measurement_list_entry_test.dart';
 
@@ -436,6 +437,11 @@ void main() {
     expect(find.byType(BloodPressureForm), findsNothing);
     expect(find.byType(WeightForm), findsOneWidget);
   });
+
+  test('correctly creates AddEntryFormValue from note only FullEntry', () {
+    final FullEntry entry = mockEntry(note: 'Test');
+    expect(entry.asAddEntry.note?.note, 'Test');
+  });
 }
 
 class _MockBluetoothCubit extends Fake implements BluetoothCubit {