typed_deep_links 0.3.1
typed_deep_links: ^0.3.1 copied to clipboard
Type-safe, router-agnostic deep links with generated URI parsing and building.
import 'package:flutter/material.dart';
import 'package:typed_deep_links/typed_deep_links.dart';
import 'package:typed_deep_links_flutter/typed_deep_links_flutter.dart';
import 'deep_links.dart';
void main() => runApp(const TypedDeepLinksExampleApp());
const appRouteInformationParser = TypedDeepLinkRouteInformationParser<AppLink>(
router: AppLinks.router,
);
class TypedDeepLinksExampleApp extends StatelessWidget {
const TypedDeepLinksExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'typed_deep_links',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff5b5bd6)),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
useMaterial3: true,
),
home: const DeepLinkPlaygroundPage(),
);
}
}
class DeepLinkPlaygroundPage extends StatefulWidget {
const DeepLinkPlaygroundPage({super.key});
@override
State<DeepLinkPlaygroundPage> createState() => _DeepLinkPlaygroundPageState();
}
class _DeepLinkPlaygroundPageState extends State<DeepLinkPlaygroundPage> {
final _uriController = TextEditingController(
text: '/orders/42?source=notification',
);
AppLink? _parsedLink;
DeepLinkException? _error;
@override
void initState() {
super.initState();
_parse();
}
@override
void dispose() {
_uriController.dispose();
super.dispose();
}
void _parse() {
final result = AppLinks.parseResult(Uri.parse(_uriController.text.trim()));
setState(() {
switch (result) {
case DeepLinkSuccess(:final value):
_parsedLink = value;
_error = null;
case DeepLinkFailure(:final error):
_parsedLink = null;
_error = error;
}
});
}
void _useUri(Uri uri) {
_uriController.text = uri.toString();
_parse();
}
void _openParsedLink() {
final link = _parsedLink;
if (link == null) return;
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => LinkDestinationPage(link: link)),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('typed_deep_links')),
body: SafeArea(
child: SelectionArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 40),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 820),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Router-independent, typed deep links',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Enter any URI. Generated code matches its route, '
'validates values, and creates a typed Dart object.',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
_SectionCard(
title: '1. Parse a URI',
icon: Icons.link,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
key: const ValueKey('uriInput'),
controller: _uriController,
decoration: const InputDecoration(
labelText: 'Deep-link URI',
hintText: '/orders/42?source=email',
prefixIcon: Icon(Icons.language),
),
keyboardType: TextInputType.url,
onSubmitted: (_) => _parse(),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
key: const ValueKey('parseButton'),
onPressed: _parse,
icon: const Icon(Icons.bolt),
label: const Text('Parse link'),
),
),
const SizedBox(height: 18),
Text(
'Try a sample',
style: theme.textTheme.labelLarge,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _samples
.map(
(sample) => ActionChip(
label: Text(sample.label),
onPressed: () =>
_useUri(Uri.parse(sample.location)),
),
)
.toList(),
),
],
),
),
const SizedBox(height: 16),
_ParseResultCard(
link: _parsedLink,
error: _error,
onOpen: _openParsedLink,
),
const SizedBox(height: 16),
_SectionCard(
title: '3. Generate URIs from typed values',
icon: Icons.outbound,
child: Column(
children: [
_GeneratedLinkTile(
code: "OrderLink(id: 108, source: 'email').toUri()",
uri: const OrderLink(
id: 108,
source: 'email',
).toUri(),
onUse: _useUri,
),
const Divider(height: 24),
_GeneratedLinkTile(
code:
"ProductLink(ProductSlug('trail-shoes'), "
"preview: true).toUri()",
uri: const ProductLink(
ProductSlug('trail-shoes'),
preview: true,
).toUri(),
onUse: _useUri,
),
const Divider(height: 24),
_GeneratedLinkTile(
code: "ProfileLink.fromUri('ada').toUri()",
uri: const ProfileLink.fromUri('ada').toUri(),
onUse: _useUri,
),
],
),
),
],
),
),
),
),
),
),
);
}
}
const _samples = <({String label, String location})>[
(label: 'Order', location: '/orders/42?source=email'),
(label: 'Product', location: '/products/red-shoes?preview=true'),
(label: 'Search', location: '/search?q=boots#reviews'),
(label: 'App scheme', location: 'myapp://open/profiles/ada'),
(label: 'Catch-all', location: '/docs/guides/getting-started'),
(label: 'Legacy alias', location: '/purchases/42?source=email'),
(label: 'Invalid value', location: '/orders/not-a-number'),
];
class _ParseResultCard extends StatelessWidget {
const _ParseResultCard({
required this.link,
required this.error,
required this.onOpen,
});
final AppLink? link;
final DeepLinkException? error;
final VoidCallback onOpen;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (error case final error?) {
return _SectionCard(
title: '2. Parse failed',
icon: Icons.error_outline,
color: theme.colorScheme.errorContainer,
child: Text(
error.toString(),
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
);
}
final value = link;
if (value == null) return const SizedBox.shrink();
return _SectionCard(
title: '2. Parsed ${value.runtimeType}',
icon: Icons.check_circle_outline,
color: theme.colorScheme.primaryContainer,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(_describeLink(value), style: theme.textTheme.titleMedium),
const SizedBox(height: 6),
Text('Round trip: ${AppLinks.toUri(value)}'),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: FilledButton.tonalIcon(
onPressed: onOpen,
icon: const Icon(Icons.open_in_new),
label: const Text('Open with Navigator'),
),
),
],
),
);
}
}
class _GeneratedLinkTile extends StatelessWidget {
const _GeneratedLinkTile({
required this.code,
required this.uri,
required this.onUse,
});
final String code;
final Uri uri;
final ValueChanged<Uri> onUse;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(code, style: theme.textTheme.bodySmall),
const SizedBox(height: 5),
Text(
uri.toString(),
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
],
),
),
TextButton(onPressed: () => onUse(uri), child: const Text('Try it')),
],
);
}
}
class _SectionCard extends StatelessWidget {
const _SectionCard({
required this.title,
required this.icon,
required this.child,
this.color,
});
final String title;
final IconData icon;
final Widget child;
final Color? color;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
color: color,
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(icon, size: 21),
const SizedBox(width: 9),
Expanded(
child: Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 18),
child,
],
),
),
);
}
}
class LinkDestinationPage extends StatelessWidget {
const LinkDestinationPage({required this.link, super.key});
final AppLink link;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(link.runtimeType.toString())),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.route, size: 72),
const SizedBox(height: 20),
Text(
_destinationTitle(link),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Navigator received a generated typed value—not raw URI maps.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
),
);
}
}
String _describeLink(AppLink link) => switch (link) {
OrderLink(:final id, :final source) =>
'Order id: $id${source == null ? '' : ' • source: $source'}',
ProductLink(:final slug, :final preview) =>
'Product slug: $slug • preview: $preview',
SearchLink(:final query, :final section) =>
'Search: ${query ?? '(empty)'} • section: ${section ?? '(none)'}',
ProfileLink(:final username) => 'Profile username: $username',
DocsLink(:final sections) => 'Docs path: ${sections.join(' / ')}',
};
String _destinationTitle(AppLink link) => switch (link) {
OrderLink(:final id) => 'Showing order #$id',
ProductLink(:final slug) => 'Showing product “$slug”',
SearchLink(:final query) => 'Showing results for “${query ?? ''}”',
ProfileLink(:final username) => 'Showing @$username',
DocsLink(:final sections) => 'Showing docs: ${sections.join(' / ')}',
};