- Now Playing shows where music plays and opens the phone's output switcher - Settings hub: Wi-Fi-only downloads (default on), storage, sound, help, legal - Privacy Policy and Terms bundled as Markdown (assets/legal), shown in-app - 'Is this your song?' report form feeding takedown_requests (+ kind, user_id) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
3.5 KiB
Dart
100 lines
3.5 KiB
Dart
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../../app/theme.dart';
|
|
|
|
/// Shows a bundled Markdown document (privacy policy, terms). Supports the
|
|
/// small subset those files use: #/## headings, "- " bullets, paragraphs,
|
|
/// **bold** and email addresses (tappable).
|
|
class DocumentScreen extends StatelessWidget {
|
|
const DocumentScreen({super.key, required this.title, required this.asset});
|
|
|
|
final String title;
|
|
final String asset;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(title)),
|
|
body: FutureBuilder<String>(
|
|
future: rootBundle.loadString(asset),
|
|
builder: (context, snap) {
|
|
if (!snap.hasData) return const Center(child: CircularProgressIndicator());
|
|
return SelectionArea(
|
|
child: ListView(
|
|
padding: const EdgeInsets.fromLTRB(20, 4, 20, 40),
|
|
children: _render(context, snap.data!),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _render(BuildContext context, String md) {
|
|
final text = Theme.of(context).textTheme;
|
|
final body = text.bodyLarge?.copyWith(color: NkColors.mutedOnPhoto, height: 1.55);
|
|
final out = <Widget>[];
|
|
for (final block in md.split(RegExp(r'\n\s*\n'))) {
|
|
final lines = block.trim().split('\n');
|
|
if (lines.first.isEmpty) continue;
|
|
if (lines.first.startsWith('# ')) {
|
|
continue; // the app bar already shows the title
|
|
} else if (lines.first.startsWith('## ')) {
|
|
out.add(Padding(
|
|
padding: const EdgeInsets.only(top: 18, bottom: 6),
|
|
child: Text(lines.first.substring(3), style: text.titleLarge),
|
|
));
|
|
} else if (lines.every((l) => l.startsWith('- '))) {
|
|
for (final l in lines) {
|
|
out.add(Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('• ', style: body?.copyWith(color: NkColors.sun)),
|
|
Expanded(child: Text.rich(_inline(l.substring(2), body), style: body)),
|
|
],
|
|
),
|
|
));
|
|
}
|
|
out.add(const SizedBox(height: 6));
|
|
} else {
|
|
out.add(Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Text.rich(_inline(lines.join('\n'), body), style: body),
|
|
));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/// **bold** and tappable email addresses.
|
|
static TextSpan _inline(String s, TextStyle? base) {
|
|
final spans = <InlineSpan>[];
|
|
final pattern = RegExp(r'\*\*(.+?)\*\*|([\w.+-]+@[\w-]+\.[\w.]+)');
|
|
var last = 0;
|
|
for (final m in pattern.allMatches(s)) {
|
|
if (m.start > last) spans.add(TextSpan(text: s.substring(last, m.start)));
|
|
if (m.group(1) != null) {
|
|
spans.add(TextSpan(
|
|
text: m.group(1),
|
|
style: const TextStyle(fontWeight: FontWeight.w700, color: NkColors.cream),
|
|
));
|
|
} else {
|
|
final email = m.group(2)!;
|
|
spans.add(TextSpan(
|
|
text: email,
|
|
style: const TextStyle(color: NkColors.sun),
|
|
recognizer: TapGestureRecognizer()..onTap = () => launchUrl(Uri(scheme: 'mailto', path: email)),
|
|
));
|
|
}
|
|
last = m.end;
|
|
}
|
|
if (last < s.length) spans.add(TextSpan(text: s.substring(last)));
|
|
return TextSpan(children: spans, style: base);
|
|
}
|
|
}
|