todo-app/lib/ui/markdown_view.dart
Krzysztof kuhy Rudnicki 2ed9267e06 warm-tint the unified-design-system palette, fix design audit violations
Replaces the inline ColorScheme.fromSeed(Colors.deepPurple) theme with a new
lib/ui/theme.dart built from explicit light/dark ColorSchemes on the frozen
unified-design-system tokens (same shared spacing/radius/type-scale constants
as diet-guard/habit_stack's theme.dart), and adds darkTheme (previously
absent).

Fixes 2 of 3 violations in DESIGN_AUDIT_TODO.md:
- settings_screen.dart's one hardcoded Colors.red -> colorScheme.error,
  matching every other error-state text in the app
- markdown_view.dart and note_editor.dart's raw-mode TextField now cap their
  reading/editing column at kProseMaxWidth (640) via Center+ConstrainedBox --
  the desktop build is an arbitrarily wide Chrome --app window, so text was
  running the full viewport. Deliberately no extra Column introduced around
  the raw-mode TextField, per its own documented expands:true/nested-Column
  regression history.

The 3rd violation (a few off-scale SizedBox/EdgeInsets values, e.g. 6px vs
the file's dominant 4px scale) was attempted but reverted: bumping the
_EnumChips/_MultiChips/_DateRangeField 6px gaps to 8px broke 3 real tests --
the filter bottom sheet's "Apply" button is tight enough against its edge
that even a 2px shift pushes it out of the hit-testable area. Not worth
trading a working control for a cosmetic 2px scale nitpick; left at 6px.

Verified: flutter analyze clean, dart format clean on all touched files,
188/188 tests passing (added test/theme_test.dart for the new file, which
had 0% coverage on its own), 100% total line coverage maintained, re-verified
identical in an isolated worktree at HEAD (narrowly staged around this
repo's own pre-existing uncommitted CLAUDE.md edit, left untouched). A
Flutter-web headless-Chrome screenshot check for the desktop target was
attempted but blocked by a CanvasKit/WebGL rendering issue in this sandbox,
unrelated to the app itself; the widget test suite (which does catch real
RenderFlex overflow, as confirmed during this same rollout) passed clean for
both touched screens instead. Mobile on-device verification (phone-deploy)
is pending -- the phone isn't connected via ADB right now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:56:45 +02:00

118 lines
3.6 KiB
Dart

import 'package:flutter/material.dart';
import 'package:todo/ui/theme.dart';
/// A lightweight, read-only renderer for the note format.
///
/// The app's notes use a small, known Markdown subset — an `#` title, `##`
/// section headings, italic `_guidance_` lines, `-` bullet lists and plain
/// paragraphs — so a tailored line-based renderer covers everything we emit
/// without pulling in a full Markdown engine. It keeps the quick-capture app
/// lean and is trivial to test.
///
/// The whole rendered note is wrapped in a [SelectionArea] so it can be copied
/// out in one go (the note is meant to be pasted into an LLM).
class MarkdownView extends StatelessWidget {
/// Creates a [MarkdownView] rendering [text].
const MarkdownView({required this.text, super.key});
/// The raw markdown source to render.
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final widgets = <Widget>[];
for (final line in text.split('\n')) {
final trimmed = line.trim();
if (trimmed.isEmpty) {
widgets.add(const SizedBox(height: 8));
continue;
}
if (trimmed.startsWith('## ')) {
widgets.add(
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Text(
trimmed.substring(3).trim(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
);
continue;
}
if (trimmed.startsWith('# ')) {
widgets.add(
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
trimmed.substring(2).trim(),
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
);
continue;
}
// A whole-line italic marker — used for the section guidance lines.
if (trimmed.length >= 2 &&
trimmed.startsWith('_') &&
trimmed.endsWith('_')) {
widgets.add(
Text(
trimmed.substring(1, trimmed.length - 1),
style: theme.textTheme.bodyMedium?.copyWith(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurfaceVariant,
),
),
);
continue;
}
if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
widgets.add(
Padding(
padding: const EdgeInsets.only(left: 4, top: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('• ', style: theme.textTheme.bodyLarge),
Expanded(
child: Text(
trimmed.substring(2),
style: theme.textTheme.bodyLarge,
),
),
],
),
),
);
continue;
}
widgets.add(Text(line, style: theme.textTheme.bodyLarge));
}
return SelectionArea(
child: SingleChildScrollView(
// Caps the reading column at ~70 chars (rule 21) — the desktop
// build is an arbitrarily wide Chrome `--app` window, so without
// this, body text runs the full viewport width.
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kProseMaxWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widgets,
),
),
),
),
);
}
}