Extended Canvas + Improved designs

This commit is contained in:
Andrei Diaconu 2021-06-10 19:56:43 +03:00
Родитель 8a2251f04c
Коммит 0b24ce1f1f
11 изменённых файлов: 678 добавлений и 425 удалений

Просмотреть файл

@ -45,7 +45,7 @@ class ToolsPane extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
if (constraints.maxHeight > 200 && constraints.maxWidth > 400) {
if (constraints.maxHeight > 250 && constraints.maxWidth > 400) {
return LargeToolsPane();
} else {
return SmallToolsPane();

Просмотреть файл

@ -40,22 +40,129 @@ class _DualViewNotepadState extends State<DualViewNotepad> {
],
),
body: TwoPane(
pane1: TextField(
controller: textController,
maxLines: 999,
decoration: const InputDecoration(
contentPadding: const EdgeInsets.all(16.0),
pane1: DraftSavedMessage(
child: PaneDecorations(
header: Text('Editor'),
contentColor: Colors.white,
headerColor: Colors.blue[200]!,
child: TextField(
controller: textController,
maxLines: 999,
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.all(16),
),
style: GoogleFonts.robotoMono(),
onChanged: (text) {
setState(() {
this.data = text;
});
},
),
),
style: GoogleFonts.robotoMono(),
onChanged: (text) {
setState(() {
this.data = text;
});
},
),
pane2: Markdown(data: data),
pane2: PaneDecorations(
header: Text('Preview'),
contentColor: Colors.transparent,
headerColor: Colors.green[200]!,
child: Markdown(data: data),
),
panePriority: panePriority,
),
);
}
}
class DraftSavedMessage extends StatelessWidget {
const DraftSavedMessage({
Key? key,
required this.child,
}) : super(key: key);
final Widget child;
@override
Widget build(BuildContext context) {
return Stack(
children: [
child,
Positioned(
bottom: 32,
left: 32,
right: 32,
child: Material(
borderRadius: BorderRadius.circular(6),
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(child: Text('Your drafts have been saved!')),
Text(
'Close',
style: TextStyle(color: Colors.blue),
),
],
),
),
),
)
],
);
}
}
class PaneDecorations extends StatelessWidget {
const PaneDecorations({
Key? key,
required this.contentColor,
required this.headerColor,
required this.header,
required this.child,
}) : super(key: key);
final Color contentColor;
final Color headerColor;
final Text header;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey[600]!, width: 1),
borderRadius: BorderRadius.circular(6),
color: contentColor,
),
margin: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(6),
topRight: Radius.circular(6),
),
color: headerColor,
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: DefaultTextStyle(
style: TextStyle(fontSize: 16, color: Colors.black),
child: header,
),
),
),
Container(
height: 1,
color: Colors.grey[600],
),
Expanded(
child: child,
),
],
),
);
}
}

Просмотреть файл

@ -32,7 +32,7 @@ const List<Restaurant> restaurants_repo = [
priceRange: 3,
rating: 4.4,
voteCount: 2303304,
latLong: LatLong(380, 356),
latLong: LatLong(0.380, 0.356),
),
const Restaurant(
name: 'Sam\'s Pizza',
@ -43,7 +43,7 @@ const List<Restaurant> restaurants_repo = [
priceRange: 2,
rating: 4.9,
voteCount: 1343,
latLong: LatLong(280, 56),
latLong: LatLong(-0.280, -0.56),
),
const Restaurant(
name: 'Sizzle and Crunch',
@ -54,7 +54,7 @@ const List<Restaurant> restaurants_repo = [
priceRange: 2,
rating: 3.9,
voteCount: 966,
latLong: LatLong(180, 216),
latLong: LatLong(0.180, -0.216),
),
const Restaurant(
name: 'Cantinetta',
@ -65,7 +65,7 @@ const List<Restaurant> restaurants_repo = [
priceRange: 4,
rating: 4.6,
voteCount: 1322,
latLong: LatLong(80, 356),
latLong: LatLong(-0.80, 0.356),
),
const Restaurant(
name: 'Araya\'s Place',
@ -76,7 +76,7 @@ const List<Restaurant> restaurants_repo = [
priceRange: 2,
rating: 4.6,
voteCount: 1322,
latLong: LatLong(90, 210),
latLong: LatLong(0.90, 0.210),
),
const Restaurant(
name: 'Kimchi Bistro',
@ -87,7 +87,7 @@ const List<Restaurant> restaurants_repo = [
priceRange: 4,
rating: 3.6,
voteCount: 4565,
latLong: LatLong(230, 396),
latLong: LatLong(-0.230, -0.396),
),
const Restaurant(
name: 'Topolopompo Restaurant',
@ -98,7 +98,7 @@ const List<Restaurant> restaurants_repo = [
priceRange: 3,
rating: 4.5,
voteCount: 6001,
latLong: LatLong(294, 226),
latLong: LatLong(0.294, -0.226),
),
const Restaurant(
name: 'Morsel',
@ -109,6 +109,6 @@ const List<Restaurant> restaurants_repo = [
priceRange: 3,
rating: 4.7,
voteCount: 787,
latLong: LatLong(180, 331),
latLong: LatLong(-0.180, 0.331),
)
];

Просмотреть файл

@ -187,7 +187,6 @@ class MapPane extends StatelessWidget {
markers: normalMarkers.map((e) => e.latLong).toList(),
selectedMarker: selectedMarker?.latLong,
onMarkerSelected: (index) {
print(index);
if (index == null) {
onPinTap(null);
} else {

Просмотреть файл

@ -1,5 +1,6 @@
import 'package:dual_screen_samples/dual_view_restaurants/data.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
/// Shows a summary of a restaurant.
///
@ -32,46 +33,49 @@ class RestaurantListItem extends StatelessWidget {
width: 140,
),
SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
restaurant.name,
style: Theme.of(context).textTheme.subtitle1,
),
SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
restaurant.rating.toStringAsFixed(1),
style: Theme.of(context).textTheme.caption,
),
SizedBox(width: 6),
Rating(rating: restaurant.rating),
SizedBox(width: 6),
Text(
'(${restaurant.voteCount})',
style: Theme.of(context).textTheme.caption,
),
],
),
SizedBox(height: 6),
Text(
'${restaurant.type}$price',
style: Theme.of(context).textTheme.caption,
),
SizedBox(height: 14),
Text(
'✓ Open now',
style: Theme.of(context)
.textTheme
.caption!
.copyWith(color: Colors.lightGreen[800]),
),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
restaurant.name,
style: Theme.of(context).textTheme.subtitle1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
restaurant.rating.toStringAsFixed(1),
style: Theme.of(context).textTheme.caption,
),
SizedBox(width: 6),
Rating(rating: restaurant.rating),
SizedBox(width: 6),
Text(
'(${restaurant.voteCount})',
style: Theme.of(context).textTheme.caption,
),
],
),
SizedBox(height: 6),
Text(
'${restaurant.type}$price',
style: Theme.of(context).textTheme.caption,
),
SizedBox(height: 14),
Text(
'✓ Open now',
style: Theme.of(context)
.textTheme
.caption!
.copyWith(color: Colors.lightGreen[800]),
),
],
),
),
Expanded(child: Container()),
// Expanded(child: Container()),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@ -99,7 +103,7 @@ class RestaurantListItem extends StatelessWidget {
/// A real map implementation would require API keys or other configuration and
/// this sample needs to be simple and require no configuration from developers.
/// Implementation details are not relevant to the sample.
class FakeMap extends StatelessWidget {
class FakeMap extends StatefulWidget {
final List<LatLong> markers;
final LatLong? selectedMarker;
final ValueChanged<int?> onMarkerSelected;
@ -111,53 +115,81 @@ class FakeMap extends StatelessWidget {
required this.onMarkerSelected,
}) : super(key: key);
@override
_FakeMapState createState() => _FakeMapState();
}
class _FakeMapState extends State<FakeMap> {
TransformationController transformationController =
new TransformationController();
double scale = 1.0;
@override
void initState() {
super.initState();
transformationController.addListener(() {
setState(() {
Matrix4 v = transformationController.value;
scale = v.entry(0, 0);
});
});
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: () {
onMarkerSelected.call(null);
},
child: Image.asset(
'images/dual_view_restaurants/city_map.png',
fit: BoxFit.cover,
),
),
),
...markers.map(
(e) => Positioned(
left: e.lat,
top: e.long,
return InteractiveViewer(
transformationController: transformationController,
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: () {
onMarkerSelected.call(markers.indexOf(e));
widget.onMarkerSelected.call(null);
},
child: Container(
decoration:
ShapeDecoration(shape: CircleBorder(), color: Colors.white),
padding: EdgeInsets.all(6),
child: Icon(
Icons.restaurant,
color: Colors.deepOrange,
size: 16,
child: Image.asset(
'images/dual_view_restaurants/city_map.png',
fit: BoxFit.cover,
),
),
),
...widget.markers.map(
(e) => Align(
alignment: Alignment(e.lat, e.long),
child: Transform.scale(
scale: 1 / scale,
child: GestureDetector(
onTap: () {
widget.onMarkerSelected.call(widget.markers.indexOf(e));
},
child: Container(
decoration: ShapeDecoration(
shape: CircleBorder(), color: Colors.white),
padding: EdgeInsets.all(6),
child: Icon(
Icons.restaurant,
color: Colors.deepOrange,
size: 16,
),
),
),
),
),
),
),
if (selectedMarker != null)
Positioned(
left: selectedMarker!.lat,
top: selectedMarker!.long - 14,
child: Icon(
Icons.location_pin,
color: Colors.red[800]!,
size: 32,
if (widget.selectedMarker != null)
Align(
alignment: Alignment(
widget.selectedMarker!.lat, widget.selectedMarker!.long),
child: Transform.scale(
scale: 1 / scale,
child: Icon(
Icons.location_pin,
color: Colors.red[800]!,
size: 32,
),
),
),
),
],
],
),
);
}
}
@ -176,100 +208,11 @@ class RestaurantScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final price = List.generate(restaurant.priceRange, (index) => '\$').join();
return Scaffold(
appBar: AppBar(title: Text(restaurant.name)),
body: TwoPane(
pane1: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Image.asset(
restaurant.picture,
height: 140,
),
Padding(
padding: const EdgeInsets.all(3.0),
child: GenericBox(height: 134, width: 134),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(3.0),
child: GenericBox(height: 134),
),
),
],
),
SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
restaurant.rating.toStringAsFixed(1),
style: Theme.of(context).textTheme.caption,
),
SizedBox(width: 6),
Rating(rating: restaurant.rating),
SizedBox(width: 6),
Text(
'(${restaurant.voteCount})',
style: Theme.of(context).textTheme.caption,
),
Expanded(child: Container()),
Text(
'✓ Open now',
style: Theme.of(context)
.textTheme
.caption!
.copyWith(color: Colors.lightGreen[800]),
),
],
),
SizedBox(height: 6),
Text(
'${restaurant.type}$price',
style: Theme.of(context).textTheme.caption,
),
SizedBox(height: 12),
GenericBox(height: 14, width: double.infinity),
SizedBox(height: 12),
GenericBox(height: 14, width: double.infinity),
SizedBox(height: 12),
GenericBox(height: 14, width: double.infinity),
SizedBox(height: 14),
Row(
children: [
Expanded(
child: GenericBox(height: 100, width: double.infinity)),
SizedBox(width: 12),
Expanded(
child: GenericBox(height: 100, width: double.infinity)),
],
),
SizedBox(height: 12),
GenericBox(height: 14, width: double.infinity),
SizedBox(height: 12),
GenericBox(height: 150, width: double.infinity),
],
),
),
),
pane2: Stack(
children: [
Positioned.fill(child: Image.asset('images/dual_view_restaurants/city_map.png', fit: BoxFit.cover)),
Center(
child: Icon(
Icons.location_pin,
color: Colors.red[800]!,
size: 32,
),
)
],
),
pane1: RestaurantDetails(restaurant: restaurant),
pane2: RestaurantDetailsSecondScreen(restaurant: restaurant),
panePriority: MediaQuery.of(context).hinge == null
? TwoPanePriority.pane1
: TwoPanePriority.both,
@ -278,6 +221,151 @@ class RestaurantScreen extends StatelessWidget {
}
}
class RestaurantDetails extends StatelessWidget {
const RestaurantDetails({
Key? key,
required this.restaurant,
}) : super(key: key);
final Restaurant restaurant;
@override
Widget build(BuildContext context) {
final price = List.generate(restaurant.priceRange, (index) => '\$').join();
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Image.asset(
restaurant.picture,
height: 140,
),
Padding(
padding: const EdgeInsets.all(3.0),
child: GenericBox(height: 134, width: 134),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(3.0),
child: GenericBox(height: 134),
),
),
],
),
SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
restaurant.rating.toStringAsFixed(1),
style: Theme.of(context).textTheme.caption,
),
SizedBox(width: 6),
Rating(rating: restaurant.rating),
SizedBox(width: 6),
Text(
'(${restaurant.voteCount})',
style: Theme.of(context).textTheme.caption,
),
Expanded(child: Container()),
Text(
'✓ Open now',
style: Theme.of(context)
.textTheme
.caption!
.copyWith(color: Colors.lightGreen[800]),
),
],
),
SizedBox(height: 6),
Text(
'${restaurant.type}$price',
style: Theme.of(context).textTheme.caption,
),
SizedBox(height: 12),
Text(
restaurant.description,
style: Theme.of(context).textTheme.caption,
),
SizedBox(height: 12),
GenericBox(height: 14, width: double.infinity),
SizedBox(height: 12),
GenericBox(height: 14, width: double.infinity),
SizedBox(height: 12),
GenericBox(height: 14, width: double.infinity),
SizedBox(height: 14),
Row(
children: [
Expanded(
child: GenericBox(height: 100, width: double.infinity)),
SizedBox(width: 12),
Expanded(
child: GenericBox(height: 100, width: double.infinity)),
],
),
SizedBox(height: 12),
GenericBox(height: 14, width: double.infinity),
SizedBox(height: 12),
GenericBox(height: 150, width: double.infinity),
],
),
),
);
}
}
class RestaurantDetailsSecondScreen extends StatelessWidget {
const RestaurantDetailsSecondScreen({
Key? key,
required this.restaurant,
}) : super(key: key);
final Restaurant restaurant;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GenericBox(height: 150, width: double.infinity),
SizedBox(height: 12),
GenericBox(height: 14, width: double.infinity),
SizedBox(height: 12),
GenericBox(height: 14, width: double.infinity),
SizedBox(height: 12),
GenericBox(height: 14, width: double.infinity),
SizedBox(height: 14),
Row(
children: [
Expanded(
child: GenericBox(height: 100, width: double.infinity)),
SizedBox(width: 12),
Expanded(
child: GenericBox(height: 100, width: double.infinity)),
],
),
SizedBox(height: 12),
GenericBox(height: 14, width: double.infinity),
SizedBox(height: 12),
GenericBox(height: 150, width: double.infinity),
SizedBox(height: 12),
GenericBox(height: 14, width: double.infinity),
SizedBox(height: 12),
GenericBox(height: 14, width: double.infinity),
],
),
),
);
}
}
/// Grey box used to fill the screen with temporary content.
class GenericBox extends StatelessWidget {
final double? width, height;
@ -300,21 +388,20 @@ class Rating extends StatelessWidget {
final double rating;
static const int maxRating = 5;
const Rating({Key? key, required this.rating})
: super(key: key);
const Rating({Key? key, required this.rating}) : super(key: key);
@override
Widget build(BuildContext context) {
return ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (bounds) => LinearGradient(
colors: [Colors.yellow[700]!, Colors.yellow[700]!, Colors.grey],
stops: [0.0, rating / maxRating, rating / maxRating])
colors: [Colors.yellow[700]!, Colors.yellow[700]!, Colors.grey],
stops: [0.0, rating / maxRating, rating / maxRating])
.createShader(bounds),
child: Row(
children:
List.generate(maxRating, (index) => Icon(Icons.star, size: 14)),
List.generate(maxRating, (index) => Icon(Icons.star, size: 14)),
),
);
}
}
}

Просмотреть файл

@ -0,0 +1,46 @@
import 'package:dual_screen_samples/dual_view_restaurants/data.dart';
import 'package:dual_screen_samples/dual_view_restaurants/dual_view_restaurants.dart';
import 'package:dual_screen_samples/dual_view_restaurants/mock_widgets.dart';
import 'package:flutter/material.dart';
class ExtendedCanvas extends StatefulWidget {
const ExtendedCanvas({Key? key}) : super(key: key);
@override
_ExtendedCanvasState createState() => _ExtendedCanvasState();
}
class _ExtendedCanvasState extends State<ExtendedCanvas> {
final List<Restaurant> restaurants = restaurants_repo;
int? selectedRestaurant;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Extended Canvas')),
body: MapPane(
restaurants: restaurants,
selectedRestaurant: selectedRestaurant,
onPinTap: (index) {
openRestaurant(context, index);
},
onPopupTap: (index) {},
singleScreen: false,
),
);
}
void openRestaurant(BuildContext context, int? index) async {
setState(() {
selectedRestaurant = index;
});
if (index != null) {
await showModalBottomSheet(context: context, builder: (context) {
return RestaurantDetails(restaurant: restaurants[index]);
}, anchorPoint: Offset(0.0, double.infinity));
setState(() {
selectedRestaurant = null;
});
}
}
}

Просмотреть файл

@ -13,74 +13,77 @@ class HingeAngle extends StatelessWidget {
appBar: AppBar(
title: const Text('Hinge Angle'),
),
body: TwoPane(
pane1: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Table(
columnWidths: {
0: FractionColumnWidth(0.3),
1: FractionColumnWidth(0.1),
},
children: [
row(
Text('Variable'),
Text('Value'),
Text('Explanation'),
body: OrientationBuilder(
builder: (context, orientation) => TwoPane(
panePriority: TwoPanePriority.both,
direction: orientation == Orientation.landscape
? Axis.horizontal
: Axis.vertical,
paneProportion: orientation == Orientation.landscape ? 0.6 : 0.4,
pane1: Container(
padding: const EdgeInsets.all(16.0),
alignment: Alignment.center,
child: Table(
border: TableBorder.all(color: Colors.black),
columnWidths: {
0: FractionColumnWidth(0.3),
1: FractionColumnWidth(0.11),
},
children: [
row(
Text('Variable'),
Text('Value'),
Text('Explanation'),
),
row(
Text('MediaQuery\nposture'),
Text(format(MediaQuery.of(context).displayFeatures)),
Text(
'The Posture is enough for most UX you want to build. This is reported only if the app is spanned.'),
),
row(
Text('DualScreenInfo.\nhasHingeAngleSensor'),
FutureBuilder<bool>(
future: DualScreenInfo.hasHingeAngleSensor,
builder: (context, hasHingeAngleSensor) {
return Text(
hasHingeAngleSensor.data?.toString() ?? 'N/A');
},
),
row(
Text('MediaQuery\nposture'),
Text(format(MediaQuery.of(context).displayFeatures)),
Text(
'The Posture is enough for most UX you want to build. This is reported only if the app is spanned.'),
Text(
'Both foldable and dual screen devices have hinges. Use this property to know if the device has a hinge angle sensor.'),
),
row(
Text('DualScreenInfo.\nhingeAngleEvents'),
StreamBuilder<double>(
stream: DualScreenInfo.hingeAngleEvents,
builder: (context, hingeAngle) {
return Text(
hingeAngle.data?.toStringAsFixed(2) ?? 'N/A*');
},
),
row(
Text('DualScreenInfo.\nhasHingeAngleSensor'),
FutureBuilder<bool>(
future: DualScreenInfo.hasHingeAngleSensor,
builder: (context, hasHingeAngleSensor) {
return Text(
hasHingeAngleSensor.data?.toString() ?? 'N/A');
},
),
Text(
'Both foldable and dual screen devices have hinges. Use this property to know if the device has a hinge angle sensor.'),
),
row(
Text('DualScreenInfo.\nhingeAngleEvents'),
StreamBuilder<double>(
stream: DualScreenInfo.hingeAngleEvents,
builder: (context, hingeAngle) {
return Text(
hingeAngle.data?.toStringAsFixed(2) ?? 'N/A*');
},
),
Text(
'Stream<double> with the latest hinge angle. The angle is reported even when the app is not spanned.'),
),
],
),
Expanded(
child: StreamBuilder<double>(
stream: DualScreenInfo.hingeAngleEvents,
builder: (context, hingeAngle) {
if (hingeAngle.data == null) {
return Center(
child: Text(
'* This sample shows you what the value of the hinge angle is. This requires a foldable or dual screen device or emulator. The emulator has an "Extended Controls" window where you can change the angle.'));
} else {
return AngleIndicator(angle: hingeAngle.data!);
}
},
))
],
Text(
'Stream<double> with the latest hinge angle. The angle is reported even when the app is not spanned.'),
),
],
),
),
pane2: Padding(
padding: const EdgeInsets.all(16.0),
child: StreamBuilder<double>(
stream: DualScreenInfo.hingeAngleEvents,
builder: (context, hingeAngle) {
if (hingeAngle.data == null) {
return Center(
child: Text(
'* This sample shows you what the value of the hinge angle is. This requires a foldable or dual screen device or emulator. The emulator has an "Extended Controls" window where you can change the angle.'));
} else {
return AngleIndicator(angle: hingeAngle.data!);
}
},
),
),
),
pane2: Container(),
panePriority: MediaQuery.of(context).hinge == null
? TwoPanePriority.pane1
: TwoPanePriority.both,
),
);
}
@ -155,14 +158,14 @@ class AngleIndicator extends StatelessWidget {
),
),
),
Positioned(
bottom: 100,
left: 0.0,
right: 0.0,
child: Text(
'${angle.toStringAsFixed(2)}°',
style: TextStyle(fontSize: 64),
textAlign: TextAlign.center,
Center(
child: Padding(
padding: const EdgeInsets.only(top: 70.0),
child: Text(
'${angle.toStringAsFixed(2)}°',
style: TextStyle(fontSize: 48),
textAlign: TextAlign.center,
),
),
)
],

Просмотреть файл

@ -9,38 +9,43 @@ class ListDetail extends StatefulWidget {
class _ListDetailState extends State<ListDetail> {
final List<String> images = List.generate(
11, (index) => 'images/list_detail/list_details_image_${index + 1}.png');
12, (index) => 'images/list_detail/list_details_image_${index + 1}.png');
int? selected;
@override
Widget build(BuildContext context) {
bool singleScreen = MediaQuery.of(context).hinge == null;
return Scaffold(
appBar: AppBar(
title: Text('List Detail'),
),
body: TwoPane(
pane1: ListPane(
images: images,
selected: selected,
onImageTap: (index) {
setState(() {
this.selected = index;
});
if (singleScreen && index != null) {
Navigator.of(context).push(
SingleScreenExclusiveRoute(
builder: (context) => DetailsScreen(image: images[index]),
),
);
}
},
singleScreen: singleScreen,
bool singleScreen = MediaQuery.of(context).hinge?.bounds?.top != 0.0;
print('Single screen $singleScreen');
return Theme(
data: ThemeData.dark(),
child: Scaffold(
appBar: AppBar(
title: Text('List Detail'),
),
body: TwoPane(
pane1: ListPane(
images: images,
selected: selected,
onImageTap: (index) {
setState(() {
this.selected = index;
});
if (singleScreen && index != null) {
Navigator.of(context).push(
SingleScreenExclusiveRoute(
builder: (context) => DetailsScreen(image: images[index]),
),
);
}
},
singleScreen: singleScreen,
),
pane2:
DetailsPane(image: selected == null ? null : images[selected!]),
panePriority: singleScreen
? TwoPanePriority.pane1
: TwoPanePriority.both,
),
pane2: DetailsPane(image: selected == null ? null : images[selected!]),
panePriority: MediaQuery.of(context).hinge != null
? TwoPanePriority.both
: TwoPanePriority.pane1,
),
);
}
@ -103,80 +108,46 @@ class DetailsPane extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
child: image == null
? Center(child: Text('Pick an image from the grid.'))
: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Image.asset(image!),
),
Padding(
padding: const EdgeInsets.only(left: 32.0),
child: Row(children: [
return image == null
? Center(child: Text('Pick an image from the grid.'))
: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Center(
child: ListTile(
leading: Icon(
Icons.camera,
size: 42,
color: Colors.black,
),
title: Text('Camera'),
subtitle: Text('f/2.0 2.5mm ISO 520'),
),
),
child: Image.asset(image!),
),
Expanded(
child: ListTile(
leading: Icon(
Icons.camera_alt,
size: 42,
color: Colors.black,
),
title: Text('Device'),
subtitle: Text('Surface Duo'),
),
)
]),
),
Padding(
padding: const EdgeInsets.only(left: 32.0),
child: Row(children: [
Expanded(
child: Center(
child: ListTile(
leading: Icon(
Icons.location_pin,
size: 42,
color: Colors.black,
Padding(
padding: const EdgeInsets.only(left: 32.0),
child: Row(children: [
Expanded(
child: Center(
child: ListTile(
leading: Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0),
child: Icon(Icons.camera, size: 30),
),
title: Text('Camera'),
subtitle: Text('f/2.0 2.5mm ISO 520'),
),
),
title: Text('Location'),
subtitle: Text('Redmond, Washington'),
),
),
Expanded(
child: ListTile(
leading: Padding(
padding: const EdgeInsets.symmetric(vertical: 10.0),
child: Icon(Icons.camera_alt, size: 30),
),
title: Text('Device'),
subtitle: Text('Surface Duo'),
),
)
]),
),
Expanded(
child: ListTile(
leading: Icon(
Icons.event,
size: 42,
color: Colors.black,
),
title: Text('Date'),
subtitle: Text('Sunday, March 25th'),
),
)
]),
)
],
),
)
);
],
),
);
}
}
@ -188,14 +159,17 @@ class DetailsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Image Details')),
body: DetailsPane(image: image),
return Theme(
data: ThemeData.dark(),
child: Scaffold(
appBar: AppBar(title: Text('Image Details')),
body: DetailsPane(image: image),
),
);
}
}
/// Route that auto-removes itself if the app is unspanned.
/// Route that auto-removes itself if the app spanned horizontally.
class SingleScreenExclusiveRoute<T> extends MaterialPageRoute<T> {
SingleScreenExclusiveRoute({
required WidgetBuilder builder,
@ -203,7 +177,7 @@ class SingleScreenExclusiveRoute<T> extends MaterialPageRoute<T> {
@override
Widget buildContent(BuildContext context) {
if (MediaQuery.of(context).hinge != null) {
if (MediaQuery.of(context).hinge?.bounds?.top == 0.0) {
navigator?.removeRoute(this);
}
return super.buildContent(context);

Просмотреть файл

@ -1,6 +1,7 @@
import 'package:dual_screen_samples/companion_pane/companion_pane.dart';
import 'package:dual_screen_samples/dual_view_notepad/dual_view_notepad.dart';
import 'package:dual_screen_samples/dual_view_restaurants/dual_view_restaurants.dart';
import 'package:dual_screen_samples/extended_canvas/extended_canvas.dart';
import 'package:dual_screen_samples/hinge_angle/hinge_angle.dart';
import 'package:dual_screen_samples/list_detail/list_detail.dart';
import 'package:dual_screen_samples/two_page/two_page.dart';
@ -21,7 +22,7 @@ List<SampleMeta> sampleCatalogue = [
),
SampleMeta(
'Dual View Notepad',
'Markdown editor where you edit on one screen and preview on the other.',
'Edit markdown on one screen and preview results on the other.',
'https://github.com/microsoft/flutter-dualscreen-samples/blob/main/lib/dual_view_notepad/dual_view_notepad.dart',
'/dual-view-notepad',
(context) => DualViewNotepad(),
@ -49,11 +50,18 @@ List<SampleMeta> sampleCatalogue = [
),
SampleMeta(
'List Detail',
'Select an item from a list and see the details about it on the other screen.',
'List of images on one screen and image details on the other screen.',
'https://github.com/microsoft/flutter-dualscreen-samples/blob/main/lib/list_detail/list_detail.dart',
'/list-detail',
(context) => ListDetail(),
),
SampleMeta(
'Extended Canvas',
'View locations on a map extended across both screens.',
'https://github.com/microsoft/flutter-dualscreen-samples/blob/main/lib/extended_canvas/extended_canvas.dart',
'/extended-canvas',
(context) => ExtendedCanvas(),
),
];
class SampleMeta {

Просмотреть файл

@ -22,7 +22,7 @@ class Page1 extends StatelessWidget {
),
),
),
SizedBox(height: landscape ? 15 : 30),
SizedBox(height: landscape ? 15 : 40),
Center(
child: Image.asset('images/two_page_rome_image.png',
height: landscape ? 180 : 240),
@ -39,11 +39,11 @@ class Page1 extends StatelessWidget {
),
),
),
SizedBox(height: landscape ? 10 : 20),
SizedBox(height: landscape ? 10 : 40),
Text(
'Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source.',
style: GoogleFonts.notoSerif(
textStyle: TextStyle(fontSize: landscape ? 14 : 16),
textStyle: TextStyle(fontSize: 16),
),
),
Expanded(child: Container()),
@ -86,14 +86,14 @@ class Page2 extends StatelessWidget {
),
),
),
SizedBox(height: 10),
SizedBox(height: 20),
Text(
'It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout. The point of using Lorem Ipsum is that it has a more-or-less normal distribution of letters, as opposed to using \'Content here, content here\', making it look like readable English. Many desktop publishing packages and web page editors now use Lorem Ipsum as their default model text, and a search for \'lorem ipsum\' will uncover many web sites still in their infancy',
style: GoogleFonts.notoSerif(
textStyle: TextStyle(fontSize: landscape ? 14 : 16),
textStyle: TextStyle(fontSize: 16),
),
),
SizedBox(height: 10),
SizedBox(height: 20),
Text(
'Where can I get some?',
style: GoogleFonts.notoSerif(
@ -103,11 +103,11 @@ class Page2 extends StatelessWidget {
),
),
),
SizedBox(height: 10),
SizedBox(height: 20),
Text(
'There are many variations of passages of Lorem Ipsum available, but the majority have suffered alteration in some form, by injected humour, or randomised words which don\'t look even slightly believable. If you are going to use a passage of Lorem Ipsum, you need to be sure there isn\'t anything embarrassing hidden in the middle of text. All the Lorem Ipsum generators on the Internet tend to repeat predefined chunks as necessary, making this the first true generator on the Internet. It uses a dictionary of over 200 Latin words, combined with a handful of model sentence structures, to generate Lorem Ipsum which looks reasonable.',
style: GoogleFonts.notoSerif(
textStyle: TextStyle(fontSize: landscape ? 14 : 16),
textStyle: TextStyle(fontSize: 16),
),
),
Expanded(child: Container()),
@ -150,7 +150,7 @@ class Page3 extends StatelessWidget {
),
),
),
SizedBox(height: 10),
SizedBox(height: 20),
Text(
'''
Until recently, the prevailing view assumed lorem ipsum was born as a nonsense text. "It\'s not Latin, though it looks like it, and it actually says nothing," Before & After magazine answered a curious reader, "Its \'words\' loosely approximate the frequency with which letters occur in English, which is why at a glance it looks pretty real.&quot;
@ -161,7 +161,7 @@ The placeholder text, beginning with the line "Lorem ipsum dolor sit amet, conse
Richard McClintock, a Latin scholar from Hampden-Sydney College, is credited with discovering the source behind the ubiquitous filler text. In seeing a sample of lorem ipsum, his interest was piqued by consectetura genuine, albeit rare, Latin word. Consulting a Latin dictionary led McClintock to a passage from De Finibus Bonorum et Malorum ("On the Extremes of Good and Evil"), a first-century B.C. text from the Roman philosopher Cicero.''',
style: GoogleFonts.notoSerif(
textStyle: TextStyle(fontSize: landscape ? 14 : 16),
textStyle: TextStyle(fontSize: 16),
),
),
Expanded(child: Container()),
@ -205,14 +205,14 @@ class Page4 extends StatelessWidget {
),
),
),
SizedBox(height: 10),
SizedBox(height: 20),
Text(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam hendrerit nisi sed sollicitudin pellentesque. Nunc posuere purus rhoncus pulvinar aliquam. Ut aliquet tristique nisl vitae volutpat. Nulla aliquet porttitor venenatis. Donec a dui et dui fringilla consectetur id nec massa. Aliquam erat volutpat. Sed ut dui ut lacus dictum fermentum vel tincidunt neque. Sed sed lacinia lectus. Duis sit amet sodales felis. Duis nunc eros, mattis at dui ac, convallis semper risus. In adipiscing ultrices tellus, in suscipit massa vehicula eu.',
style: GoogleFonts.notoSerif(
textStyle: TextStyle(fontSize: landscape ? 14 : 16),
textStyle: TextStyle(fontSize: 16),
),
),
SizedBox(height: 10),
SizedBox(height: 20),
Text(
'Boparai\'s version:',
style: GoogleFonts.notoSerif(
@ -222,11 +222,11 @@ class Page4 extends StatelessWidget {
),
),
),
SizedBox(height: 10),
SizedBox(height: 20),
Text(
'Rrow itself, let it be sorrow; let him love it; let him pursue it, ishing for its acquisitiendum. Because he will ab hold, uniess but through concer, and also of those who resist. Now a pure snore disturbeded sum dust. He ejjnoyes, in order that somewon, also with a severe one, unless of life. May a cusstums offficer somewon nothing of a poison-filled. Until, from a twho, twho chaffinch may also pursue it, not even a lump. But as twho, as a tank; a proverb, yeast; or else they tinscribe nor. Yet yet dewlap bed. Twho may be, let him love fellows of a polecat. Now amour, the, twhose being, drunk, yet twhitch and, an enclosed valleys always a laugh.',
style: GoogleFonts.notoSerif(
textStyle: TextStyle(fontSize: landscape ? 14 : 16),
textStyle: TextStyle(fontSize: 16),
),
),
Expanded(child: Container()),

Просмотреть файл

@ -1,9 +1,27 @@
import 'package:dual_screen_samples/two_page/data.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class TwoPage extends StatelessWidget {
class TwoPage extends StatefulWidget {
const TwoPage({Key? key}) : super(key: key);
@override
_TwoPageState createState() => _TwoPageState();
}
class _TwoPageState extends State<TwoPage> {
@override
void initState() {
super.initState();
SystemChrome.setEnabledSystemUIOverlays([]);
}
@override
void dispose() {
super.dispose();
SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values);
}
@override
Widget build(BuildContext context) {
// Default values for single screen mode
@ -26,53 +44,64 @@ class TwoPage extends StatelessWidget {
viewPortFraction = pageSize.width / MediaQuery.of(context).size.width;
} else {
// Dual-screen with screens top-and-bottom
final barsHeight =
MediaQuery.of(context).viewPadding.top + kToolbarHeight;
pageSize = Size(MediaQuery.of(context).size.width,
MediaQuery.of(context).hinge!.bounds.bottom - barsHeight);
MediaQuery.of(context).hinge!.bounds.bottom);
pagePadding = EdgeInsets.only(bottom: hingeSize.height);
lastPageSize = Size(
pageSize.width, pageSize.height + barsHeight - hingeSize.height);
lastPagePadding = EdgeInsets.only(bottom: barsHeight);
lastPageSize = Size(pageSize.width, pageSize.height - hingeSize.height);
lastPagePadding = EdgeInsets.only();
viewPortFraction =
pageSize.height / (MediaQuery.of(context).size.height - barsHeight);
pageSize.height / (MediaQuery.of(context).size.height);
}
}
return Scaffold(
appBar: AppBar(
title: Text('Two Page'),
),
body: ListView(
scrollDirection: axis,
physics: PageScrollPhysics(),
controller: PageController(viewportFraction: viewPortFraction),
body: Stack(
children: [
Container(
width: pageSize.width,
height: pageSize.height,
padding: pagePadding,
child: Page1(),
),
Container(
width: pageSize.width,
height: pageSize.height,
padding: pagePadding,
child: Page2(),
),
Container(
width: pageSize.width,
height: pageSize.height,
padding: pagePadding,
child: Page3(),
),
Container(
width: lastPageSize.width,
height: lastPageSize.height,
padding: lastPagePadding,
child: Page4(),
ListView(
scrollDirection: axis,
physics: PageScrollPhysics(),
controller: PageController(viewportFraction: viewPortFraction),
children: [
Container(
width: pageSize.width,
height: pageSize.height,
padding: pagePadding,
child: Page1(),
),
Container(
width: pageSize.width,
height: pageSize.height,
padding: pagePadding,
child: Page2(),
),
Container(
width: pageSize.width,
height: pageSize.height,
padding: pagePadding,
child: Page3(),
),
Container(
width: lastPageSize.width,
height: lastPageSize.height,
padding: lastPagePadding,
child: Page4(),
),
],
),
Positioned(
bottom: 12,
left: 12,
child: FloatingActionButton(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
mini: true,
onPressed: () {
Navigator.of(context).pop();
},
child: Icon(Icons.arrow_back),
),
)
],
),
);
}
}
}