1
0
mirror of https://github.com/flutter/samples.git synced 2025-11-08 13:58:47 +00:00

Add a Material/Cupertino adaptive application example (#69)

This commit is contained in:
xster
2019-06-10 14:14:34 -07:00
committed by Andrew Brogdon
parent 08beb69245
commit 325c5a5d2b
67 changed files with 2718 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'songs_tab.dart';
import 'news_tab.dart';
import 'profile_tab.dart';
import 'settings_tab.dart';
import 'widgets.dart';
void main() => runApp(MyAdaptingApp());
class MyAdaptingApp extends StatelessWidget {
@override
Widget build(context) {
// Change this value to better see animations.
timeDilation = 1;
// Either Material or Cupertino widgets work in either Material or Cupertino
// Apps.
return MaterialApp(
title: 'Adaptive Music App',
theme: ThemeData(
// Use the green theme for Material widgets.
primarySwatch: Colors.green,
),
builder: (context, child) {
return CupertinoTheme(
// Instead of letting Cupertino widgets auto-adapt to the Material
// theme (which is green), this app will use a different theme
// for Cupertino (which is blue by default).
data: CupertinoThemeData(),
child: Material(child: child),
);
},
home: PlatformAdaptingHomePage(),
);
}
}
// Shows a different type of scaffold depending on the platform.
//
// This file has the most amount of non-sharable code since it behaves the most
// differently between the platforms.
//
// These differences are also subjective and have more than one 'right' answer
// depending on the app and content.
class PlatformAdaptingHomePage extends StatefulWidget {
@override
_PlatformAdaptingHomePageState createState() => _PlatformAdaptingHomePageState();
}
class _PlatformAdaptingHomePageState extends State<PlatformAdaptingHomePage> {
// This app keeps a global key for the songs tab because it owns a bunch of
// data. Since changing platform reparents those tabs into different
// scaffolds, keeping a global key to it lets this app keep that tab's data as
// the platform toggles.
//
// This isn't needed for apps that doesn't toggle platforms while running.
final songsTabKey = GlobalKey();
// In Material, this app uses the hamburger menu paradigm and flatly lists
// all 4 possible tabs. This drawer is injected into the songs tab which is
// actually building the scaffold around the drawer.
Widget _buildAndroidHomePage(context) {
return SongsTab(
key: songsTabKey,
androidDrawer: _AndroidDrawer(),
);
}
// On iOS, the app uses a bottom tab paradigm. Here, each tab view sits inside
// a tab in the tab scaffold. The tab scaffold also positions the tab bar
// in a row at the bottom.
//
// An important thing to note is that while a Material Drawer can display a
// large number of items, a tab bar cannot. To illustrate one way of adjusting
// for this, the app folds its fourth tab (the settings page) into the
// third tab. This is a common pattern on iOS.
Widget _buildIosHomePage(context) {
return CupertinoTabScaffold(
tabBar: CupertinoTabBar(
items: [
BottomNavigationBarItem(title: Text(SongsTab.title), icon: SongsTab.iosIcon),
BottomNavigationBarItem(title: Text(NewsTab.title), icon: NewsTab.iosIcon),
BottomNavigationBarItem(title: Text(ProfileTab.title), icon: ProfileTab.iosIcon),
],
),
tabBuilder: (context, index) {
switch (index) {
case 0:
return CupertinoTabView(
defaultTitle: SongsTab.title,
builder: (context) => SongsTab(key: songsTabKey),
);
case 1:
return CupertinoTabView(
defaultTitle: NewsTab.title,
builder: (context) => NewsTab(),
);
case 2:
return CupertinoTabView(
defaultTitle: ProfileTab.title,
builder: (context) => ProfileTab(),
);
default:
assert(false, 'Unexpected tab');
return null;
}
},
);
}
@override
Widget build(context) {
return PlatformWidget(
androidBuilder: _buildAndroidHomePage,
iosBuilder: _buildIosHomePage,
);
}
}
class _AndroidDrawer extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Drawer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DrawerHeader(
decoration: BoxDecoration(color: Colors.green),
child: Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Icon(
Icons.account_circle,
color: Colors.green.shade800,
size: 96,
),
),
),
ListTile(
leading: SongsTab.androidIcon,
title: Text(SongsTab.title),
onTap: () {
Navigator.pop(context);
},
),
ListTile(
leading: NewsTab.androidIcon,
title: Text(NewsTab.title),
onTap: () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(
builder: (context) => NewsTab()
));
},
),
ListTile(
leading: ProfileTab.androidIcon,
title: Text(ProfileTab.title),
onTap: () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(
builder: (context) => ProfileTab()
));
},
),
// Long drawer contents are often segmented.
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Divider(),
),
ListTile(
leading: SettingsTab.androidIcon,
title: Text(SettingsTab.title),
onTap: () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(
builder: (context) => SettingsTab()
));
},
),
],
),
);
}
}

View File

@@ -0,0 +1,126 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_lorem/flutter_lorem.dart';
import 'utils.dart';
import 'widgets.dart';
class NewsTab extends StatefulWidget {
static const title = 'News';
static const androidIcon = Icon(Icons.library_books);
static const iosIcon = Icon(CupertinoIcons.news);
@override
_NewsTabState createState() => _NewsTabState();
}
class _NewsTabState extends State<NewsTab> {
static const _itemsLength = 20;
List<Color> colors;
List<String> titles;
List<String> contents;
@override
void initState() {
colors = getRandomColors(_itemsLength);
titles = List.generate(_itemsLength, (index) => generateRandomHeadline());
contents = List.generate(_itemsLength, (index) => lorem(paragraphs: 1, words: 24));
super.initState();
}
Widget _listBuilder(context, index) {
if (index >= _itemsLength)
return null;
return SafeArea(
top: false,
bottom: false,
child: Card(
elevation: 1.5,
margin: EdgeInsets.fromLTRB(6, 12, 6, 0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
child: InkWell(
// Make it splash on Android. It would happen automatically if this
// was a real card but this is just a demo. Skip the splash on iOS.
onTap: defaultTargetPlatform == TargetPlatform.iOS ? null : () {},
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
backgroundColor: colors[index],
child: Text(
titles[index].substring(0, 1),
style: TextStyle(color: Colors.white),
),
),
Padding(padding: EdgeInsets.only(left: 16)),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
titles[index],
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
Padding(padding: EdgeInsets.only(top: 8)),
Text(
contents[index],
),
],
),
),
],
),
),
)
),
);
}
// ===========================================================================
// Non-shared code below because this tab uses different scaffolds.
// ===========================================================================
Widget _buildAndroid(context) {
return Scaffold(
appBar: AppBar(
title: Text(NewsTab.title),
),
body: Container(
color: Colors.grey[100],
child: ListView.builder(
itemBuilder: _listBuilder,
),
),
);
}
Widget _buildIos(context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(),
child: Container(
color: Colors.grey[100],
child: ListView.builder(
itemBuilder: _listBuilder,
),
),
);
}
@override
Widget build(context) {
return PlatformWidget(
androidBuilder: _buildAndroid,
iosBuilder: _buildIos,
);
}
}

View File

@@ -0,0 +1,243 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'settings_tab.dart';
import 'widgets.dart';
class ProfileTab extends StatelessWidget {
static const title = 'Profile';
static const androidIcon = Icon(Icons.person);
static const iosIcon = Icon(CupertinoIcons.profile_circled);
Widget _buildBody(context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
Padding(
padding: EdgeInsets.all(8),
child: Center(
child: Text('😼', style: TextStyle(
fontSize: 80,
decoration: TextDecoration.none,
)),
),
),
PreferenceCard(
header: 'MY INTENSITY PREFERENCE',
content: '🔥',
preferenceChoices: [
'Super heavy',
'Dial it to 11',
"Head bangin'",
'1000W',
'My neighbor hates me',
],
),
PreferenceCard(
header: 'CURRENT MOOD',
content: '🤘🏾🚀',
preferenceChoices: [
'Over the moon',
'Basking in sunlight',
'Hello fellow Martians',
'Into the darkness',
],
),
Expanded(
child: Container(),
),
LogOutButton(),
],
),
),
);
}
// ===========================================================================
// Non-shared code below because on iOS, the settings tab is nested inside of
// the profile tab as a button in the nav bar.
// ===========================================================================
Widget _buildAndroid(context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: _buildBody(context),
);
}
Widget _buildIos(context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
trailing: CupertinoButton(
padding: EdgeInsets.zero,
child: SettingsTab.iosIcon,
onPressed: () {
// This pushes the settings page as a full page modal dialog on top
// of the tab bar and everything.
Navigator.of(context, rootNavigator: true).push(
CupertinoPageRoute(
title: SettingsTab.title,
fullscreenDialog: true,
builder: (context) => SettingsTab(),
),
);
},
),
),
child: _buildBody(context),
);
}
@override
Widget build(context) {
return PlatformWidget(
androidBuilder: _buildAndroid,
iosBuilder: _buildIos,
);
}
}
class PreferenceCard extends StatelessWidget {
const PreferenceCard({ this.header, this.content, this.preferenceChoices });
final String header;
final String content;
final List<String> preferenceChoices;
@override
Widget build(context) {
return PressableCard(
color: Colors.green,
flattenAnimation: AlwaysStoppedAnimation(0),
child: Stack(
children: [
Container(
height: 120,
width: 250,
child: Padding(
padding: EdgeInsets.only(top: 40),
child: Center(
child: Text(
content,
style: TextStyle(fontSize: 48),
),
),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
color: Colors.black12,
height: 40,
padding: EdgeInsets.only(left: 12),
alignment: Alignment.centerLeft,
child: Text(
header,
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
onPressed: () {
showChoices(context, preferenceChoices);
},
);
}
}
class LogOutButton extends StatelessWidget {
static const _logoutMessage = Text('You may check out any time you like, but you can never leave');
// ===========================================================================
// Non-shared code below because this tab shows different interfaces. On
// Android, it's showing an alert dialog with 2 buttons and on iOS,
// it's showing an action sheet with 3 choices.
//
// This is a design choice and you may want to do something different in your
// app.
// ===========================================================================
Widget _buildAndroid(context) {
return RaisedButton(
child: Text('LOG OUT', style: TextStyle(color: Colors.red)),
onPressed: () {
// You should do something with the result of the dialog prompt in a
// real app but this is just a demo.
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Log out?'),
content: _logoutMessage,
actions: [
FlatButton(
child: const Text('Go back'),
onPressed: () => Navigator.pop(context) ,
),
FlatButton(
child: const Text('Cancel'),
onPressed: () => Navigator.pop(context),
),
],
);
}
);
},
);
}
Widget _buildIos(context) {
return CupertinoButton(
color: CupertinoColors.destructiveRed,
child: Text('Log out'),
onPressed: () {
// You should do something with the result of the action sheet prompt
// in a real app but this is just a demo.
showCupertinoModalPopup(
context: context,
builder: (context) {
return CupertinoActionSheet(
title: Text('Log out?'),
message: _logoutMessage,
actions: [
CupertinoActionSheetAction(
child: const Text('Reprogram the night man'),
isDestructiveAction: true,
onPressed: () => Navigator.pop(context),
),
CupertinoActionSheetAction(
child: const Text('Go back'),
onPressed: () => Navigator.pop(context),
),
],
cancelButton: CupertinoActionSheetAction(
child: const Text('Cancel'),
isDefaultAction: true,
onPressed: () => Navigator.pop(context),
),
);
},
);
},
);
}
@override
Widget build(context) {
return PlatformWidget(
androidBuilder: _buildAndroid,
iosBuilder: _buildIos,
);
}
}

View File

@@ -0,0 +1,104 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'widgets.dart';
class SettingsTab extends StatefulWidget {
static const title = 'Settings';
static const androidIcon = Icon(Icons.settings);
static const iosIcon = Icon(CupertinoIcons.gear);
@override
_SettingsTabState createState() => _SettingsTabState();
}
class _SettingsTabState extends State<SettingsTab> {
var switch1 = false; var switch2 = true; var switch3 = true; var switch4 = true;
var switch5 = true; var switch6 = false; var switch7 = true;
Widget _buildList() {
return ListView(
children: [
Padding(padding: EdgeInsets.only(top: 24)),
ListTile(
title: Text('Send me marketing emails'),
// The Material switch has a platform adaptive constructor.
trailing: Switch.adaptive(
value: switch1,
onChanged: (value) => setState(() => switch1 = value),
),
),
ListTile(
title: Text('Enable notifications'),
trailing: Switch.adaptive(
value: switch2,
onChanged: (value) => setState(() => switch2 = value),
),
),
ListTile(
title: Text('Remind me to rate this app'),
trailing: Switch.adaptive(
value: switch3,
onChanged: (value) => setState(() => switch3 = value),
),
),
ListTile(
title: Text('Background song refresh'),
trailing: Switch.adaptive(
value: switch4,
onChanged: (value) => setState(() => switch4 = value),
),
),
ListTile(
title: Text('Recommend me songs based on my location'),
trailing: Switch.adaptive(
value: switch5,
onChanged: (value) => setState(() => switch5 = value),
),
),
ListTile(
title: Text('Auto-transition playback to cast devices'),
trailing: Switch.adaptive(
value: switch6,
onChanged: (value) => setState(() => switch6 = value),
),
),
ListTile(
title: Text('Find friends from my contact list'),
trailing: Switch.adaptive(
value: switch7,
onChanged: (value) => setState(() => switch7 = value),
),
),
],
);
}
// ===========================================================================
// Non-shared code below because this tab uses different scaffolds.
// ===========================================================================
Widget _buildAndroid(context) {
return Scaffold(
appBar: AppBar(
title: Text(SettingsTab.title),
),
body: _buildList(),
);
}
Widget _buildIos(context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(),
child: _buildList(),
);
}
@override
Widget build(context) {
return PlatformWidget(
androidBuilder: _buildAndroid,
iosBuilder: _buildIos,
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'widgets.dart';
// Page shown when a card in the songs tab is tapped.
//
// On Android, this page sits at the top of your app. On iOS, this page is on
// top of the songs tab's content but is below the tab bar itself.
class SongDetailTab extends StatelessWidget {
const SongDetailTab({ this.id, this.song, this.color });
final int id;
final String song;
final Color color;
Widget _buildBody() {
return SafeArea(
bottom: false,
left: false,
right: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Hero(
tag: id,
child: HeroAnimatingSongCard(
song: song,
color: color,
heroAnimation: AlwaysStoppedAnimation(1),
),
// This app uses a flightShuttleBuilder to specify the exact widget
// to build while the hero transition is mid-flight.
//
// It could either be specified here or in SongsTab.
flightShuttleBuilder: (context, animation, flightDirection, fromHeroContext, toHeroContext) {
return HeroAnimatingSongCard(
song: song,
color: color,
heroAnimation: animation,
);
},
),
Divider(
height: 0,
color: Colors.grey,
),
Expanded(
child: ListView.builder(
itemCount: 10,
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.only(left: 15, top: 16, bottom: 8),
child: Text('You might also like:', style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
)),
);
}
// Just a bunch of boxes that simulates loading song choices.
return SongPlaceholderTile();
},
),
),
],
),
);
}
// ===========================================================================
// Non-shared code below because we're using different scaffolds.
// ===========================================================================
Widget _buildAndroid(context) {
return Scaffold(
appBar: AppBar(title: Text(song)),
body: _buildBody(),
);
}
Widget _buildIos(context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text(song),
previousPageTitle: 'Songs',
),
child: _buildBody(),
);
}
@override
Widget build(context) {
return PlatformWidget(
androidBuilder: _buildAndroid,
iosBuilder: _buildIos,
);
}
}

View File

@@ -0,0 +1,168 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'song_detail_tab.dart';
import 'utils.dart';
import 'widgets.dart';
class SongsTab extends StatefulWidget {
static const title = 'Songs';
static const androidIcon = Icon(Icons.music_note);
static const iosIcon = Icon(CupertinoIcons.music_note);
const SongsTab({ Key key, this.androidDrawer }) : super(key: key);
final Widget androidDrawer;
@override
_SongsTabState createState() => _SongsTabState();
}
class _SongsTabState extends State<SongsTab> {
static const _itemsLength = 50;
final _androidRefreshKey = GlobalKey<RefreshIndicatorState>();
List<MaterialColor> colors;
List<String> songNames;
@override
void initState() {
_setData();
super.initState();
}
void _setData() {
colors = getRandomColors(_itemsLength);
songNames = getRandomNames(_itemsLength);
}
Future<void> _refreshData() {
return Future.delayed(
// This is just an arbitrary delay that simulates some network activity.
const Duration(seconds: 2),
() => setState(() => _setData()),
);
}
Widget _listBuilder(context, index) {
if (index >= _itemsLength)
return null;
// Show a slightly different color palette. Show poppy-ier colors on iOS
// due to lighter contrasting bars and tone it down on Android.
final color = defaultTargetPlatform == TargetPlatform.iOS
? colors[index]
: colors[index].shade400;
return SafeArea(
top: false,
bottom: false,
child: Hero(
tag: index,
child: HeroAnimatingSongCard(
song: songNames[index],
color: color,
heroAnimation: AlwaysStoppedAnimation(0),
onPressed: () => Navigator.of(context).push(MaterialPageRoute(
builder: (context) => SongDetailTab(
id: index,
song: songNames[index],
color: color,
),
)),
),
),
);
}
void _togglePlatform() {
TargetPlatform _getOppositePlatform() {
if (defaultTargetPlatform == TargetPlatform.iOS) {
return TargetPlatform.android;
} else {
return TargetPlatform.iOS;
}
}
debugDefaultTargetPlatformOverride = _getOppositePlatform();
// This rebuilds the application. This should obviously never be
// done in a real app but it's done here since this app
// unrealistically toggles the current platform for demonstration
// purposes.
WidgetsBinding.instance.reassembleApplication();
}
// ===========================================================================
// Non-shared code below because:
// - Android and iOS have different scaffolds
// - There are differenc items in the app bar / nav bar
// - Android has a hamburger drawer, iOS has bottom tabs
// - The iOS nav bar is scrollable, Android is not
// - Pull-to-refresh works differently, and Android has a button to trigger it too
//
// And these are all design time choices that doesn't have a single 'right'
// answer.
// ===========================================================================
Widget _buildAndroid(context) {
return Scaffold(
appBar: AppBar(
title: Text(SongsTab.title),
actions: [
IconButton(
icon: Icon(Icons.refresh),
onPressed: () async => await _androidRefreshKey.currentState.show(),
),
IconButton(
icon: Icon(Icons.shuffle),
onPressed: _togglePlatform,
),
],
),
drawer: widget.androidDrawer,
body: RefreshIndicator(
key: _androidRefreshKey,
onRefresh: _refreshData,
child: ListView.builder(
padding: EdgeInsets.symmetric(vertical: 12),
itemBuilder: _listBuilder,
),
),
);
}
Widget _buildIos(context) {
return CustomScrollView(
slivers: [
CupertinoSliverNavigationBar(
trailing: CupertinoButton(
padding: EdgeInsets.zero,
child: Icon(CupertinoIcons.shuffle),
onPressed: _togglePlatform,
),
),
CupertinoSliverRefreshControl(
onRefresh: _refreshData,
),
SliverSafeArea(
top: false,
sliver: SliverPadding(
padding: EdgeInsets.symmetric(vertical: 12),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(_listBuilder),
),
),
),
],
);
}
@override
Widget build(context) {
return PlatformWidget(
androidBuilder: _buildAndroid,
iosBuilder: _buildIos,
);
}
}

View File

@@ -0,0 +1,92 @@
import 'dart:math';
import 'package:english_words/english_words.dart';
// This reimplements generateWordPair because english_words's
// implementation has some performance issues.
// https://github.com/filiph/english_words/issues/9
// ignore: implementation_imports
import 'package:english_words/src/words/unsafe.dart';
import 'package:flutter/material.dart';
// This file has a number of platform-agnostic non-Widget utility functions.
const _myListOfRandomColors = [
Colors.red, Colors.blue, Colors.teal, Colors.yellow, Colors.amber,
Colors.deepOrange, Colors.green, Colors.indigo, Colors.lime, Colors.pink,
Colors.orange,
];
final _random = Random();
Iterable wordPairIterator = generateWordPair();
Iterable<WordPair> generateWordPair() sync* {
bool filterWord(word) => unsafe.contains(word);
String pickRandom(List<String> list) => list[_random.nextInt(list.length)];
String prefix;
while (true) {
if (_random.nextBool()) {
prefix = pickRandom(adjectives);
} else {
prefix = pickRandom(nouns);
}
final suffix = pickRandom(nouns);
if (filterWord(prefix) || filterWord(suffix))
continue;
final wordPair = WordPair(prefix, suffix);
yield wordPair;
}
}
String generateRandomHeadline() {
final artist = capitalizePair(wordPairIterator.first);
switch (_random.nextInt(9)) {
case 0:
return '$artist says ${nouns[_random.nextInt(nouns.length)]}';
case 1:
return '$artist arrested due to ${wordPairIterator.first.join(' ')}';
case 2:
return '$artist releases ${capitalizePair(wordPairIterator.first)}';
case 3:
return '$artist talks about his ${nouns[_random.nextInt(nouns.length)]}';
case 4:
return '$artist talks about her ${nouns[_random.nextInt(nouns.length)]}';
case 5:
return '$artist talks about their ${nouns[_random.nextInt(nouns.length)]}';
case 6:
return '$artist says their music is inspired by ${wordPairIterator.first.join(' ')}';
case 7:
return '$artist says the world needs more ${nouns[_random.nextInt(nouns.length)]}';
case 7:
return '$artist calls their band ${adjectives[_random.nextInt(adjectives.length)]}';
case 8:
return '$artist finally ready to talk about ${nouns[_random.nextInt(nouns.length)]}';
}
assert(false, 'Failed to generate news headline');
return null;
}
List<MaterialColor> getRandomColors(int amount) {
return List<MaterialColor>.generate(amount, (int index) {
return _myListOfRandomColors[_random.nextInt(_myListOfRandomColors.length)];
});
}
List<String> getRandomNames(int amount) {
return wordPairIterator
.take(amount)
.map((pair) => capitalizePair(pair))
.toList();
}
String capitalize(String word) {
return '${word[0].toUpperCase()}${word.substring(1).toLowerCase()}';
}
String capitalizePair(WordPair pair) {
return '${capitalize(pair.first)} ${capitalize(pair.second)}';
}

View File

@@ -0,0 +1,334 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
/// A simple widget that builds different things on different platforms.
class PlatformWidget extends StatelessWidget {
const PlatformWidget({
Key key,
@required this.androidBuilder,
@required this.iosBuilder,
}) : assert(androidBuilder != null),
assert(iosBuilder != null),
super(key: key);
final WidgetBuilder androidBuilder;
final WidgetBuilder iosBuilder;
@override
Widget build(context) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return androidBuilder(context);
case TargetPlatform.iOS:
return iosBuilder(context);
default:
assert(false, 'Unexpected platform $defaultTargetPlatform');
return null;
}
}
}
/// A platform-agnostic card with a high elevation that reacts when tapped.
///
/// This is an example of a custom widget that an app developer might create for
/// use on both iOS and Android as part of their brand's unique design.
class PressableCard extends StatefulWidget {
const PressableCard({
this.onPressed,
this.color,
this.flattenAnimation,
this.child,
});
final VoidCallback onPressed;
final Color color;
final Animation<double> flattenAnimation;
final Widget child;
@override
State<StatefulWidget> createState() => new _PressableCardState();
}
class _PressableCardState extends State<PressableCard> with SingleTickerProviderStateMixin {
bool pressed = false;
AnimationController controller;
Animation<double> elevationAnimation;
@override
void initState() {
controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 40),
);
elevationAnimation = controller.drive(CurveTween(curve: Curves.easeInOutCubic));
super.initState();
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
double get flatten => 1 - widget.flattenAnimation.value;
@override
Widget build(context) {
return Listener(
onPointerDown: (details) { if (widget.onPressed != null) { controller.forward(); } },
onPointerUp: (details) { controller.reverse(); },
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (widget.onPressed != null) {
widget.onPressed();
}
},
// This widget both internally drives an animation when pressed and
// responds to an external animation to flatten the card when in a
// hero animation. You likely want to modularize them more in your own
// app.
child: AnimatedBuilder(
animation: Listenable.merge([elevationAnimation, widget.flattenAnimation]),
child: widget.child,
builder: (context, child) {
return Transform.scale(
// This is just a sample. You likely want to keep the math cleaner
// in your own app.
scale: 1 - elevationAnimation.value * 0.03,
child: Padding(
padding: EdgeInsets.symmetric(vertical: 16, horizontal: 16) * flatten,
child: PhysicalModel(
elevation: ((1 - elevationAnimation.value) * 10 + 10) * flatten,
borderRadius: BorderRadius.circular(12 * flatten),
clipBehavior: Clip.antiAlias,
color: widget.color,
child: child,
),
),
);
},
),
),
);
}
}
/// A platform-agnostic card representing a song which can be in a card state,
/// a flat state or anything in between.
///
/// When it's in a card state, it's pressable.
///
/// This is an example of a custom widget that an app developer might create for
/// use on both iOS and Android as part of their brand's unique design.
class HeroAnimatingSongCard extends StatelessWidget {
HeroAnimatingSongCard({ this.song, this.color, this.heroAnimation, this.onPressed });
final String song;
final Color color;
final Animation<double> heroAnimation;
final VoidCallback onPressed;
double get playButtonSize => 50 + 50 * heroAnimation.value;
@override
Widget build(context) {
// This is an inefficient usage of AnimatedBuilder since it's rebuilding
// the entire subtree instead of passing in a non-changing child and
// building a transition widget in between.
//
// Left simple in this demo because this card doesn't have any real inner
// content so this just rebuilds everything while animating.
return AnimatedBuilder(
animation: heroAnimation,
builder: (context, child) {
return PressableCard(
onPressed: heroAnimation.value == 0 ? onPressed : null,
color: color,
flattenAnimation: heroAnimation,
child: SizedBox(
height: 250,
child: Stack(
alignment: Alignment.center,
children: [
// The song title banner slides off in the hero animation.
Positioned(
bottom: - 80 * heroAnimation.value,
left: 0,
right: 0,
child: Container(
height: 80,
color: Colors.black12,
alignment: Alignment.centerLeft,
padding: EdgeInsets.symmetric(horizontal: 12),
child: Text(
song,
style: TextStyle(
fontSize: 21,
fontWeight: FontWeight.w500,
),
),
),
),
// The play button grows in the hero animation.
Padding(
padding: EdgeInsets.only(bottom: 45) * (1 - heroAnimation.value),
child: Container(
height: playButtonSize,
width: playButtonSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black12,
),
alignment: Alignment.center,
child: Icon(Icons.play_arrow, size: playButtonSize, color: Colors.black38),
),
),
],
),
),
);
},
);
}
}
/// A loading song tile's silhouette.
///
/// This is an example of a custom widget that an app developer might create for
/// use on both iOS and Android as part of their brand's unique design.
class SongPlaceholderTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
height: 95,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 15, vertical: 8),
child: Row(
children: [
Container(
color: Colors.grey[400],
width: 130,
),
Padding(
padding: EdgeInsets.only(left: 12),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 9,
margin: EdgeInsets.only(right: 60),
color: Colors.grey[300],
),
Container(
height: 9,
margin: EdgeInsets.only(right: 20, top: 8),
color: Colors.grey[300],
),
Container(
height: 9,
margin: EdgeInsets.only(right: 40, top: 8),
color: Colors.grey[300],
),
Container(
height: 9,
margin: EdgeInsets.only(right: 80, top: 8),
color: Colors.grey[300],
),
Container(
height: 9,
margin: EdgeInsets.only(right: 50, top: 8),
color: Colors.grey[300],
),
],
),
),
],
),
),
);
}
}
// ===========================================================================
// Non-shared code below because different interfaces are shown to prompt
// for a multiple-choice answer.
//
// This is a design choice and you may want to do something different in your
// app.
// ===========================================================================
/// This uses a platform-appropriate mechanism to show users multiple choices.
///
/// On Android, it uses a dialog with radio buttons. On iOS, it uses a picker.
void showChoices(BuildContext context, List<String> choices) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
showDialog(
context: context,
builder: (context) {
int selectedRadio = 1;
return AlertDialog(
contentPadding: EdgeInsets.only(top: 12),
content: StatefulBuilder(
builder: (context, setState) {
return Column(
mainAxisSize: MainAxisSize.min,
children: List<Widget>.generate(choices.length, (index) {
return RadioListTile(
title: Text(choices[index]),
value: index,
groupValue: selectedRadio,
onChanged: (value) {
setState(() => selectedRadio = value);
},
);
}),
);
},
),
actions: [
FlatButton(
child: Text('OK'),
onPressed: () => Navigator.of(context).pop(),
),
FlatButton(
child: Text('CANCEL'),
onPressed: () => Navigator.of(context).pop(),
),
],
);
},
);
return;
case TargetPlatform.iOS:
showCupertinoModalPopup(
context: context,
builder: (context) {
return SizedBox(
height: 250,
child: CupertinoPicker(
useMagnifier: true,
magnification: 1.1,
itemExtent: 40,
scrollController: FixedExtentScrollController(initialItem: 1),
children: List<Widget>.generate(choices.length, (index) {
return Center(child: Text(
choices[index],
style: TextStyle(
fontSize: 21,
),
));
}),
onSelectedItemChanged: (value) {},
),
);
}
);
return;
default:
assert(false, 'Unexpected platform $defaultTargetPlatform');
}
}