window_title 1.0.0
window_title: ^1.0.0 copied to clipboard
Set the window, browser tab and app switcher title at runtime, with a WindowTitle widget that follows your navigation.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:window_title/window_title.dart';
void main() {
// The app-level title. It is the whole title when nothing else contributes
// one, and the last segment when something does.
WindowTitle.base = 'Demo';
runApp(const DemoApp());
}
// ────────────────────────────────────────────────────────────────── routing ──
final GoRouter _router = GoRouter(
routes: <RouteBase>[
ShellRoute(
builder: (_, _, Widget child) => DemoScaffold(child: child),
routes: <RouteBase>[
GoRoute(
path: '/',
// A route's builder is the natural home for a screen title: the
// widget is mounted for exactly as long as the route is.
builder: (_, _) =>
const WindowTitle(title: 'Home', child: HomeScreen()),
),
GoRoute(
path: '/items',
builder: (_, _) =>
const WindowTitle(title: 'Items', child: ItemsScreen()),
routes: <RouteBase>[
GoRoute(
path: ':id',
builder: (_, GoRouterState state) => WindowTitle(
// Starts generic, and is refined from deeper in the tree once
// the item has loaded.
title: 'Item',
child: ItemScreen(id: state.pathParameters['id']!),
),
),
],
),
GoRoute(
path: '/settings',
builder: (_, _) =>
const WindowTitle(title: 'Settings', child: SettingsScreen()),
),
],
),
],
);
class DemoApp extends StatelessWidget {
const DemoApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp.router(
// MaterialApp.router leaves `title` null, which is what you want: the
// package owns the title from here on.
routerConfig: _router,
theme: ThemeData(colorSchemeSeed: Colors.indigo),
darkTheme: ThemeData(
colorSchemeSeed: Colors.indigo,
brightness: Brightness.dark,
),
);
}
// ─────────────────────────────────────────────────────────────────── chrome ──
class DemoScaffold extends StatefulWidget {
const DemoScaffold({super.key, required this.child});
final Widget child;
@override
State<DemoScaffold> createState() => _DemoScaffoldState();
}
class _DemoScaffoldState extends State<DemoScaffold> {
final WindowTitleTicker _ticker = WindowTitleTicker();
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final String location = GoRouterState.of(context).uri.path;
return Scaffold(
appBar: AppBar(
// Mirrors the real window title, so the demo is legible on platforms
// that do not show one.
title: ValueListenableBuilder<String>(
valueListenable: WindowTitle.current,
builder: (_, String title, _) => Text(
title.isEmpty ? '(no title yet)' : title,
style: const TextStyle(
fontFeatures: <FontFeature>[FontFeature.tabularFigures()],
),
),
),
actions: <Widget>[
TextButton.icon(
onPressed: () => setState(
() => _ticker.isRunning ? _ticker.stop() : _ticker.start(),
),
icon: Icon(_ticker.isRunning ? Icons.stop : Icons.play_arrow),
label: Text(_ticker.isRunning ? 'Stop ticker' : 'Start ticker'),
),
],
),
body: widget.child,
bottomNavigationBar: NavigationBar(
selectedIndex: switch (location) {
final String p when p.startsWith('/items') => 1,
'/settings' => 2,
_ => 0,
},
onDestinationSelected: (int i) =>
context.go(<String>['/', '/items', '/settings'][i]),
destinations: const <Widget>[
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.list), label: 'Items'),
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
],
),
);
}
}
// ────────────────────────────────────────────────────────────────── screens ──
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.all(24),
child: Text(
'The title in the bar above mirrors the real window or tab title.\n\n'
'· Move between tabs to see the title follow the route.\n'
'· Open an item to see a title refined after an async load.\n'
'· Open the dialog to see a title stack on top of a screen.\n'
'· Start the ticker to see a decoration survive navigation.\n'
'· Use Settings to change how segments are composed.',
),
);
}
class ItemsScreen extends StatelessWidget {
const ItemsScreen({super.key});
@override
Widget build(BuildContext context) => ListView(
children: <Widget>[
for (final String id in <String>['1', '2', '3'])
ListTile(
title: Text('Item $id'),
onTap: () => context.go('/items/$id'),
),
const Divider(),
ListTile(
leading: const Icon(Icons.open_in_new),
title: const Text('Open a dialog'),
onTap: () => showDialog<void>(
context: context,
builder: (_) => const WindowTitle(
title: 'Dialog',
child: AlertDialog(
content: Text(
'A dialog is its own route, so its title wins while it is open '
'and is dropped when it closes.',
),
),
),
),
),
],
);
}
class ItemScreen extends StatefulWidget {
const ItemScreen({super.key, required this.id});
final String id;
@override
State<ItemScreen> createState() => _ItemScreenState();
}
class _ItemScreenState extends State<ItemScreen> {
String? _name;
@override
void initState() {
super.initState();
// Stands in for loading the real name from somewhere.
Future<void>.delayed(const Duration(seconds: 1), () {
if (mounted) {
setState(() => _name = 'Item ${widget.id}');
}
});
}
@override
Widget build(BuildContext context) {
final String? name = _name;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// An empty title contributes nothing, so the route's generic 'Item'
// keeps showing while this loads. Once the name arrives it takes
// over, and `standalone` drops the generic one.
WindowTitle(title: name ?? '', standalone: name != null),
Text(
name ?? 'Loading…',
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
);
}
}
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
@override
Widget build(BuildContext context) => ListView(
children: <Widget>[
ListTile(
title: const Text('Separator'),
subtitle: Text('"${WindowTitle.separator}"'),
trailing: SegmentedButton<String>(
segments: const <ButtonSegment<String>>[
ButtonSegment<String>(value: ' · ', label: Text('·')),
ButtonSegment<String>(value: ' — ', label: Text('—')),
ButtonSegment<String>(value: ' | ', label: Text('|')),
],
selected: <String>{WindowTitle.separator},
onSelectionChanged: (Set<String> value) =>
setState(() => WindowTitle.separator = value.first),
),
),
SwitchListTile(
title: const Text('Limit to 2 segments'),
value: WindowTitle.maxSegments == 2,
onChanged: (bool value) =>
setState(() => WindowTitle.maxSegments = value ? 2 : null),
),
SwitchListTile(
title: const Text('Add a prefix'),
subtitle: const Text('"(3) "'),
value: WindowTitle.prefix.isNotEmpty,
onChanged: (bool value) =>
setState(() => WindowTitle.prefix = value ? '(3) ' : ''),
),
ListTile(
title: const Text('Override the whole title for 3 seconds'),
onTap: () {
WindowTitle.overrideTitle = 'Overridden';
Future<void>.delayed(
const Duration(seconds: 3),
() => WindowTitle.overrideTitle = null,
);
},
),
],
);
}