This commit is contained in:
Andrei Diaconu 2021-05-28 12:00:48 +03:00
Родитель 453d7a2e2e
Коммит 433c426611
23 изменённых файлов: 1435 добавлений и 85 удалений

Двоичные данные
images/arayas_place_image.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 60 KiB

Двоичные данные
images/cantinetta_image.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 64 KiB

Двоичные данные
images/city_map.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 763 KiB

Двоичные данные
images/companion_pane_image.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 2.9 MiB

Двоичные данные
images/kimchi_bistro_image.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 69 KiB

Двоичные данные
images/morsel_image.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 53 KiB

Двоичные данные
images/pestle_rock_image.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 70 KiB

Двоичные данные
images/sams_pizza_image.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 71 KiB

Двоичные данные
images/sizzle_crunch_image.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 69 KiB

Двоичные данные
images/topolopompo_image.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 54 KiB

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

@ -0,0 +1,189 @@
import 'package:dual_screen_samples/companion_pane/data.dart';
import 'package:flutter/material.dart';
class CompanionPane extends StatelessWidget {
const CompanionPane({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData.dark(),
child: Scaffold(
// backgroundColor: Colors.grey[900],
appBar: AppBar(
title: Text('Companion Pane'),
),
body: TwoPane(
pane1: PreviewPane(),
pane2: ToolsPane(),
paneProportion: 0.7,
direction: Axis.vertical,
),
),
);
}
}
class PreviewPane extends StatelessWidget {
const PreviewPane({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Image.asset(
'images/companion_pane_image.png',
fit: BoxFit.contain,
),
);
}
}
class ToolsPane extends StatelessWidget {
const ToolsPane({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
if (constraints.maxHeight > 200 && constraints.maxWidth > 400) {
return LargeToolsPane();
} else {
return SmallToolsPane();
}
});
}
}
class LargeToolsPane extends StatelessWidget {
const LargeToolsPane({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: Column(
children: [
Spacer(flex: 10),
...tools
.expand((e) => [
ExpandedToolTile(tool: e),
Spacer(flex: 1),
])
.toList(),
Spacer(flex: 10),
],
),
),
),
);
}
);
}
}
class SmallToolsPane extends StatelessWidget {
const SmallToolsPane({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
children: [
Spacer(flex: 1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: ToolSlider(),
),
Spacer(flex: 1),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children:
tools.map((e) => ToolTile(tool: e, onTap: () {})).toList(),
),
),
Spacer(flex: 2),
],
);
}
}
class ExpandedToolTile extends StatelessWidget {
final Tool tool;
const ExpandedToolTile({Key? key, required this.tool}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
ToolTile(tool: tool),
SizedBox(width: 10),
Expanded(child: ToolSlider()),
],
),
);
}
}
class ToolTile extends StatelessWidget {
final Tool tool;
final VoidCallback? onTap;
const ToolTile({
Key? key,
required this.tool,
this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
width: 82,
height: 82,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(tool.icon, size: 34),
SizedBox(height: 10),
Text(tool.name, textAlign: TextAlign.center),
],
),
),
);
}
}
class ToolSlider extends StatefulWidget {
const ToolSlider({Key? key}) : super(key: key);
@override
_ToolSliderState createState() => _ToolSliderState();
}
class _ToolSliderState extends State<ToolSlider> {
double value = 0;
@override
Widget build(BuildContext context) {
return Slider(
value: value,
onChanged: (v) {
setState(() {
value = v;
});
},
);
}
}

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

@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
class Tool {
final IconData icon;
final String name;
Tool(this.icon, this.name);
}
List<Tool> tools = [
Tool(
Icons.auto_fix_high,
'Auto Fix',
),
Tool(
Icons.wb_sunny,
'Brightness',
),
Tool(
Icons.brightness_medium,
'Contrast',
),
Tool(
Icons.vignette,
'Vignette',
),
Tool(
Icons.rotate_left,
'Rotate',
),
Tool(
Icons.crop,
'Crop',
),
];

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

@ -0,0 +1,32 @@
const String initialMarkdownData = '''
# Large header (h1)
###### Small header (h6)
Text can be *italic* or **bold** or *even **both** at the same time*. It can also contain [links](https://pub.dev/packages/dual_screen).
* Lists can be
* unordered
* Item
* Item
* ordered
1. Item
1. Item
This sample can be boiled down to using `TwoPane`:
```
TwoPane(
pane1: TextField(
onChanged: (text) {
setState(() {
this.data = text;
});
},
),
pane2: Markdown(data: data),
panePriority: panePriority,
)
```
> Markdown is a lightweight markup language with plain-text-formatting syntax, created in 2004 by John Gruber with Aaron Swartz.
''';

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

@ -0,0 +1,61 @@
import 'package:dual_screen_samples/dual_view_notepad/data.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:google_fonts/google_fonts.dart';
class DualViewNotepad extends StatefulWidget {
const DualViewNotepad({Key? key}) : super(key: key);
@override
_DualViewNotepadState createState() => _DualViewNotepadState();
}
class _DualViewNotepadState extends State<DualViewNotepad> {
String data = initialMarkdownData;
bool editing = true;
TextEditingController textController =
TextEditingController(text: initialMarkdownData);
@override
Widget build(BuildContext context) {
bool singleScreen = MediaQuery.of(context).hinge == null;
var panePriority = TwoPanePriority.both;
if (singleScreen) {
panePriority = editing ? TwoPanePriority.pane1 : TwoPanePriority.pane2;
}
return Scaffold(
appBar: AppBar(
title: Text('Dual View Notepad'),
actions: [
if (singleScreen)
TextButton(
style: TextButton.styleFrom(primary: Colors.white),
onPressed: () {
setState(() {
editing = !editing;
});
},
child: Text(editing ? 'View' : 'Edit'),
)
],
),
body: TwoPane(
pane1: TextField(
controller: textController,
maxLines: 999,
decoration: const InputDecoration(
contentPadding: const EdgeInsets.all(16.0),
),
style: GoogleFonts.robotoMono(),
onChanged: (text) {
setState(() {
this.data = text;
});
},
),
pane2: Markdown(data: data),
panePriority: panePriority,
),
);
}
}

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

@ -0,0 +1,114 @@
class Restaurant {
final String name, description, picture, type;
final double rating;
final int voteCount, priceRange;
final LatLong latLong;
const Restaurant({
required this.name,
required this.description,
required this.picture,
required this.type,
required this.priceRange,
required this.rating,
required this.voteCount,
required this.latLong,
});
}
class LatLong {
final double lat, long;
const LatLong(this.lat, this.long);
}
const List<Restaurant> restaurants_repo = [
const Restaurant(
name: 'Pestle Rock',
description:
'Wine bar with upscale small plates in a lofty modern space with a central wine tower & staircase.',
picture: 'images/pestle_rock_image.png',
type: 'Thai',
priceRange: 3,
rating: 4.4,
voteCount: 2303304,
latLong: LatLong(380, 356),
),
const Restaurant(
name: 'Sam\'s Pizza',
description:
'Take-out/delivery chain offering classic & specialty pizzas, wings & breadsticks, plus desserts.',
picture: 'images/sams_pizza_image.png',
type: 'American',
priceRange: 2,
rating: 4.9,
voteCount: 1343,
latLong: LatLong(280, 56),
),
const Restaurant(
name: 'Sizzle and Crunch',
description:
'Eatery with a wood-fired oven turning out European & NW dishes in a white-&-blue cottage-like room.',
picture: 'images/sizzle_crunch_image.png',
type: 'Thai',
priceRange: 2,
rating: 3.9,
voteCount: 966,
latLong: LatLong(180, 216),
),
const Restaurant(
name: 'Cantinetta',
description:
'Gourmet Neapolitan pies served in a lofty space with casual, industrial-chic decor.',
picture: 'images/cantinetta_image.png',
type: 'Italian',
priceRange: 4,
rating: 4.6,
voteCount: 1322,
latLong: LatLong(80, 356),
),
const Restaurant(
name: 'Araya\'s Place',
description:
'Araya\'s Place is the 1st vegan-Thai restaurant in the northwest while supporting local farms.',
picture: 'images/arayas_place_image.png',
type: 'Thai',
priceRange: 2,
rating: 4.6,
voteCount: 1322,
latLong: LatLong(90, 210),
),
const Restaurant(
name: 'Kimchi Bistro',
description:
'Small, no frills Korean restaurant with an extensive menu served in simple digs inside a mall.',
picture: 'images/kimchi_bistro_image.png',
type: 'Korean',
priceRange: 4,
rating: 3.6,
voteCount: 4565,
latLong: LatLong(230, 396),
),
const Restaurant(
name: 'Topolopompo Restaurant',
description:
'Compact locale with counter service dishing up classic Mediterranean eats such as hummus & falafel.',
picture: 'images/topolopompo_image.png',
type: 'FineDine',
priceRange: 3,
rating: 4.5,
voteCount: 6001,
latLong: LatLong(294, 226),
),
const Restaurant(
name: 'Morsel',
description:
'Homey cafe with sofas, board games & quiet corners for gourmet coffee & craft biscuit sandwiches.',
picture: 'images/morsel_image.png',
type: 'Breakfast',
priceRange: 3,
rating: 4.7,
voteCount: 787,
latLong: LatLong(180, 331),
)
];

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

@ -0,0 +1,221 @@
import 'package:dual_screen_samples/dual_view_restaurants/data.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'mock_widgets.dart';
class DualViewRestaurants extends StatefulWidget {
const DualViewRestaurants({Key? key}) : super(key: key);
@override
_DualViewRestaurantsState createState() => _DualViewRestaurantsState();
}
class _DualViewRestaurantsState extends State<DualViewRestaurants> {
final List<Restaurant> restaurants = restaurants_repo;
int? selectedRestaurant;
bool showList = true;
@override
Widget build(BuildContext context) {
bool singleScreen = MediaQuery.of(context).hinge == null;
var panePriority = TwoPanePriority.both;
if (singleScreen) {
panePriority = showList ? TwoPanePriority.pane1 : TwoPanePriority.pane2;
}
return Scaffold(
appBar: AppBar(
title: Text('Dual View Restaurants'),
actions: [
if (singleScreen)
TextButton(
style: TextButton.styleFrom(primary: Colors.white),
onPressed: () {
setState(() {
showList = !showList;
});
},
child: Text(showList ? 'Map' : 'List'),
)
],
),
body: TwoPane(
pane1: ListPane(
restaurants: restaurants,
selectedRestaurant: selectedRestaurant,
singlePane: singleScreen,
onRestaurantTap: (index) {
setState(() {
this.selectedRestaurant = index;
});
if (index != null) {
openRestaurant(context, restaurants[index]);
}
},
),
pane2: MapPane(
restaurants: restaurants,
selectedRestaurant: selectedRestaurant,
onPinTap: (index) {
setState(() {
this.selectedRestaurant = index;
});
},
onPopupTap: (index) => openRestaurant(context, restaurants[index]),
singlePane: singleScreen,
),
panePriority: panePriority,
),
);
}
void openRestaurant(BuildContext context, Restaurant restaurant) {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) {
return RestaurantScreen(restaurant: restaurant);
}),
);
}
}
/// Shows a list of restaurants.
///
/// Use [singlePane] to let the widget know what if this is used alongside
/// a [MapPane] or not.
/// - If it is, then the selected pin is highlighted.
/// - If it is not, no highlighting is needed because selection is not
/// possible and does not make sense.
class ListPane extends StatefulWidget {
final List<Restaurant> restaurants;
final int? selectedRestaurant;
final ValueChanged<int?> onRestaurantTap;
final bool singlePane;
const ListPane({
Key? key,
required this.restaurants,
required this.selectedRestaurant,
required this.onRestaurantTap,
required this.singlePane,
}) : super(key: key);
@override
_ListPaneState createState() => _ListPaneState();
}
class _ListPaneState extends State<ListPane> {
late ScrollController scrollController;
static const itemHeight = 125.0;
@override
void initState() {
super.initState();
scrollController = ScrollController(
initialScrollOffset: (widget.selectedRestaurant ?? 0.0) * itemHeight);
}
@override
void didUpdateWidget(covariant ListPane oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.selectedRestaurant != null &&
oldWidget.selectedRestaurant != widget.selectedRestaurant) {
scrollController.animateTo(
widget.selectedRestaurant! * itemHeight,
duration: Duration(milliseconds: 300),
curve: Curves.fastOutSlowIn,
);
}
}
@override
Widget build(BuildContext context) {
return ListView.separated(
controller: scrollController,
itemBuilder: (ctx, index) => SizedBox(
height: itemHeight,
child: RestaurantListItem(
restaurant: widget.restaurants[index],
selected: !widget.singlePane && index == widget.selectedRestaurant,
onTap: () {
widget.onRestaurantTap(index);
},
),
),
itemCount: widget.restaurants.length,
separatorBuilder: (BuildContext context, int index) =>
Divider(height: 0.0),
);
}
}
/// Shows a map of restaurants.
///
/// Use [singlePane] to let the widget know what if this is used alongside
/// a [ListPane] or not.
/// - If it is, then we rely on the list to show details about
/// the pin we selected.
/// - If it is not, then this widget needs a way to how details about the
/// selected pin and it does this by showing a small popup at the bottom.
class MapPane extends StatelessWidget {
final List<Restaurant> restaurants;
final int? selectedRestaurant;
final ValueChanged<int?> onPinTap;
final ValueChanged<int> onPopupTap;
final bool singlePane;
const MapPane({
Key? key,
required this.restaurants,
required this.selectedRestaurant,
required this.onPinTap,
required this.onPopupTap,
required this.singlePane,
}) : super(key: key);
@override
Widget build(BuildContext context) {
Restaurant? selectedMarker =
selectedRestaurant == null ? null : restaurants[selectedRestaurant!];
List<Restaurant> normalMarkers =
restaurants.where((element) => element != selectedMarker).toList();
return Stack(
children: [
Positioned.fill(
child: FakeMap(
markers: normalMarkers.map((e) => e.latLong).toList(),
selectedMarker: selectedMarker?.latLong,
onMarkerSelected: (index) {
print(index);
if (index == null) {
onPinTap(null);
} else {
Restaurant newSelection = normalMarkers[index];
onPinTap(restaurants.indexOf(newSelection));
}
},
),
),
if (singlePane && selectedMarker != null)
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.all(32.0),
child: SizedBox(
height: 130,
child: Material(
elevation: 3,
borderRadius: BorderRadius.circular(16.0),
child: RestaurantListItem(
restaurant: selectedMarker,
onTap: () => onPopupTap(selectedRestaurant!)),
),
),
))
],
);
}
}

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

@ -0,0 +1,320 @@
import 'package:dual_screen_samples/dual_view_restaurants/data.dart';
import 'package:flutter/material.dart';
/// Shows a summary of a restaurant.
///
/// This is used to render items in the Restaurant List pane but is also used
/// to show details about the selected pin on the Restaurant Map pane in
/// single-screen mode.
class RestaurantListItem extends StatelessWidget {
final Restaurant restaurant;
final bool selected;
final VoidCallback? onTap;
const RestaurantListItem({
Key? key,
required this.restaurant,
this.selected = false,
this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final price = List.generate(restaurant.priceRange, (index) => '\$').join();
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
child: Row(
children: [
Image.asset(
restaurant.picture,
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: Container()),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (selected)
Icon(
Icons.location_pin,
color: Colors.red[800]!,
size: 32,
),
Text(
'${restaurant.rating.ceil()} min away',
style: Theme.of(context).textTheme.caption,
),
],
),
],
),
),
);
}
}
/// Simulates a map plugin.
///
/// 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 {
final List<LatLong> markers;
final LatLong? selectedMarker;
final ValueChanged<int?> onMarkerSelected;
const FakeMap({
Key? key,
this.markers = const [],
this.selectedMarker,
required this.onMarkerSelected,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: () {
onMarkerSelected.call(null);
},
child: Image.asset(
'images/city_map.png',
fit: BoxFit.cover,
),
),
),
...markers.map(
(e) => Positioned(
left: e.lat,
top: e.long,
child: GestureDetector(
onTap: () {
onMarkerSelected.call(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,
),
),
],
);
}
}
/// A separate screen for one restaurant.
///
/// In this screen the user can order food or start directions or otherwise
/// interact with the restaurant.
class RestaurantScreen extends StatelessWidget {
final Restaurant restaurant;
const RestaurantScreen({
Key? key,
required this.restaurant,
}) : super(key: key);
@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/city_map.png', fit: BoxFit.cover)),
Center(
child: Icon(
Icons.location_pin,
color: Colors.red[800]!,
size: 32,
),
)
],
),
panePriority: MediaQuery.of(context).hinge == null
? TwoPanePriority.pane1
: TwoPanePriority.both,
),
);
}
}
/// Grey box used to fill the screen with temporary content.
class GenericBox extends StatelessWidget {
final double? width, height;
const GenericBox({Key? key, this.width, this.height}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.grey, borderRadius: BorderRadius.circular(16)),
);
}
}
/// Shows a 5-star rating.
class Rating extends StatelessWidget {
final double rating;
static const int maxRating = 5;
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])
.createShader(bounds),
child: Row(
children:
List.generate(maxRating, (index) => Icon(Icons.star, size: 14)),
),
);
}
}

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

@ -0,0 +1,200 @@
import 'dart:math';
import 'package:dual_screen/dual_screen.dart';
import 'package:flutter/material.dart';
import 'dart:ui' as ui;
class HingeAngle extends StatelessWidget {
const HingeAngle({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
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'),
),
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');
},
),
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!);
}
},
))
],
),
),
pane2: Container(),
panePriority: MediaQuery.of(context).hinge == null
? TwoPanePriority.pane1
: TwoPanePriority.both,
),
);
}
TableRow row(Widget a, b, c) {
return TableRow(children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 8),
child: a,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 8),
child: b,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 8),
child: c,
),
]);
}
String format(List<ui.DisplayFeature> displayFeatures) {
if (displayFeatures.isEmpty) {
return 'N/A';
} else {
return displayFeatures.map((e) => formatPosture(e.state)).join(', ');
}
}
String formatPosture(ui.DisplayFeatureState displayFeatureState) {
final longForm = displayFeatureState.toString();
if (longForm.length < 20) {
return longForm;
} else if (longForm.length < 27) {
return longForm.substring(20);
} else {
return longForm.substring(27);
}
// switch (displayFeatureState) {
// case DisplayFeatureState.unknown:
// return 'unknown';
// case DisplayFeatureState.postureFlat:
// return 'Flat';
// case DisplayFeatureState.postureHalfOpened:
// return 'HalfOpened';
// case DisplayFeatureState.postureFlipped:
// return 'Flipped';
// }
// return 'unknown';
}
}
class AngleIndicator extends StatelessWidget {
final double angle;
const AngleIndicator({Key? key, required this.angle}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: AspectRatio(
aspectRatio: 1.0,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Stack(
children: [
Positioned.fill(
child: CustomPaint(
painter: AnglePainter(
angle: angle,
color: Theme.of(context).primaryColor,
),
),
),
Positioned(
bottom: 100,
left: 0.0,
right: 0.0,
child: Text(
'${angle.toStringAsFixed(2)}°',
style: TextStyle(fontSize: 64),
textAlign: TextAlign.center,
),
)
],
),
),
),
);
}
}
class AnglePainter extends CustomPainter {
final double angle;
final Color color;
AnglePainter({required this.angle, this.color = Colors.black});
@override
void paint(Canvas canvas, Size size) {
var paint1 = Paint()
..color = color
..style = PaintingStyle.fill;
final radians = angle * (pi / 180);
canvas.drawArc(
Offset(0, 0) & size,
-pi / 2 - radians / 2, //radians
radians, //radians
true,
paint1,
);
}
@override
bool shouldRepaint(AnglePainter oldDelegate) =>
oldDelegate.angle != angle || oldDelegate.color != color;
}

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

@ -1,4 +1,8 @@
import 'package:dual_screen_samples/two_page.dart';
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/hinge_angle/hinge_angle.dart';
import 'package:dual_screen_samples/two_page/two_page.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
@ -9,17 +13,38 @@ void main() {
List<SampleMeta> sampleCatalogue = [
SampleMeta(
'Two Page',
'A book-like reading experience with swipe gestures',
'A book-like reading experience. You can see two pages simultaneously.',
'https://github.com/microsoft/flutter-dualscreen-samples/blob/main/lib/two_page/two_page.dart',
'/two-page',
(context) => TwoPageSample(),
(context) => TwoPage(),
),
SampleMeta(
'Two Page 2',
'A book-like reading experience with swipe gestures',
'https://github.com/microsoft/flutter-dualscreen-samples/blob/main/lib/two_page/two_page.dart',
'/two-page2',
(context) => TwoPageSample(),
'Dual View Notepad',
'Markdown editor where you edit on one screen and preview 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(),
),
SampleMeta(
'Dual View Restaurants',
'A list of restaurants on one screen and a map with pins on the other.',
'https://github.com/microsoft/flutter-dualscreen-samples/blob/main/lib/dual_view_restaurants/dual_view_restaurants.dart',
'/dual-view-restaurants',
(context) => DualViewRestaurants(),
),
SampleMeta(
'Companion Pane',
'Image editor with a preview on one screen and the filters on the other.',
'https://github.com/microsoft/flutter-dualscreen-samples/blob/main/lib/companion_pane/companion_pane.dart',
'/companion-pane',
(context) => CompanionPane(),
),
SampleMeta(
'Hinge Angle',
'Interact with the hinge hardware to see the angle change the UI.',
'https://github.com/microsoft/flutter-dualscreen-samples/blob/main/lib/hinge_angle/hinge_angle.dart',
'/hinge-angle',
(context) => HingeAngle(),
),
];

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

@ -1,82 +1,6 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class TwoPageSample extends StatelessWidget {
const TwoPageSample({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// Default values for single screen mode
Axis axis = Axis.horizontal;
double viewPortFraction = 1.0;
Size pageSize = MediaQuery.of(context).size;
Size lastPageSize = MediaQuery.of(context).size;
EdgeInsets pagePadding = EdgeInsets.zero;
EdgeInsets lastPagePadding = EdgeInsets.zero;
final isDualScreen = MediaQuery.of(context).hinge != null;
if (isDualScreen) {
final Size hingeSize = MediaQuery.of(context).hinge!.bounds.size;
axis = hingeSize.aspectRatio > 1.0 ? Axis.vertical : Axis.horizontal;
if (axis == Axis.horizontal) {
// Dual-screen with screens left-and-right
pageSize = Size(MediaQuery.of(context).hinge!.bounds.right,
MediaQuery.of(context).size.height);
pagePadding = EdgeInsets.only(right: hingeSize.width);
lastPageSize = Size(pageSize.width - hingeSize.width, pageSize.height);
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);
pagePadding = EdgeInsets.only(bottom: hingeSize.height);
lastPageSize = Size(
pageSize.width, pageSize.height + barsHeight - hingeSize.height);
lastPagePadding = EdgeInsets.only(bottom: barsHeight);
viewPortFraction =
pageSize.height / (MediaQuery.of(context).size.height - barsHeight);
}
}
return Scaffold(
appBar: AppBar(
title: Text('Two Page Sample'),
),
body: 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(),
),
],
),
);
}
}
class Page1 extends StatelessWidget {
const Page1({Key? key}) : super(key: key);

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

@ -0,0 +1,78 @@
import 'package:dual_screen_samples/two_page/data.dart';
import 'package:flutter/material.dart';
class TwoPage extends StatelessWidget {
const TwoPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// Default values for single screen mode
Axis axis = Axis.horizontal;
double viewPortFraction = 1.0;
Size pageSize = MediaQuery.of(context).size;
Size lastPageSize = MediaQuery.of(context).size;
EdgeInsets pagePadding = EdgeInsets.zero;
EdgeInsets lastPagePadding = EdgeInsets.zero;
final isDualScreen = MediaQuery.of(context).hinge != null;
if (isDualScreen) {
final Size hingeSize = MediaQuery.of(context).hinge!.bounds.size;
axis = hingeSize.aspectRatio > 1.0 ? Axis.vertical : Axis.horizontal;
if (axis == Axis.horizontal) {
// Dual-screen with screens left-and-right
pageSize = Size(MediaQuery.of(context).hinge!.bounds.right,
MediaQuery.of(context).size.height);
pagePadding = EdgeInsets.only(right: hingeSize.width);
lastPageSize = Size(pageSize.width - hingeSize.width, pageSize.height);
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);
pagePadding = EdgeInsets.only(bottom: hingeSize.height);
lastPageSize = Size(
pageSize.width, pageSize.height + barsHeight - hingeSize.height);
lastPagePadding = EdgeInsets.only(bottom: barsHeight);
viewPortFraction =
pageSize.height / (MediaQuery.of(context).size.height - barsHeight);
}
}
return Scaffold(
appBar: AppBar(
title: Text('Two Page'),
),
body: 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(),
),
],
),
);
}
}

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

@ -1,6 +1,20 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
ansicolor:
dependency: transitive
description:
name: ansicolor
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
args:
dependency: transitive
description:
name: args
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
async:
dependency: transitive
description:
@ -43,6 +57,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.15.0"
console_log_handler:
dependency: transitive
description:
name: console_log_handler
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.6"
crypto:
dependency: transitive
description:
@ -50,6 +71,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
dual_screen:
dependency: "direct main"
description:
path: "../dual_screen"
relative: true
source: path
version: "1.0.0"
fake_async:
dependency: transitive
description:
@ -76,6 +104,27 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_image:
dependency: transitive
description:
name: flutter_image
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
flutter_map:
dependency: "direct main"
description:
name: flutter_map
url: "https://pub.dartlang.org"
source: hosted
version: "0.12.0"
flutter_markdown:
dependency: "direct main"
description:
name: flutter_markdown
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.2"
flutter_test:
dependency: "direct dev"
description: flutter
@ -107,6 +156,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.0"
intl:
dependency: transitive
description:
name: intl
url: "https://pub.dartlang.org"
source: hosted
version: "0.17.0"
js:
dependency: transitive
description:
@ -114,6 +170,34 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.3"
latlong:
dependency: transitive
description:
name: latlong
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.1"
lists:
dependency: transitive
description:
name: lists
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.6"
logging:
dependency: transitive
description:
name: logging
url: "https://pub.dartlang.org"
source: hosted
version: "0.11.4"
markdown:
dependency: transitive
description:
name: markdown
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.0"
matcher:
dependency: transitive
description:
@ -128,6 +212,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0"
mgrs_dart:
dependency: transitive
description:
name: mgrs_dart
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
path:
dependency: transitive
description:
@ -191,6 +282,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
positioned_tap_detector:
dependency: transitive
description:
name: positioned_tap_detector
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
process:
dependency: transitive
description:
@ -198,6 +296,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.1"
proj4dart:
dependency: transitive
description:
name: proj4dart
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.5"
quiver:
dependency: transitive
description:
name: quiver
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.5"
sky_engine:
dependency: transitive
description: flutter
@ -245,6 +357,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.19"
transparent_image:
dependency: transitive
description:
name: transparent_image
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
tuple:
dependency: transitive
description:
name: tuple
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.3"
typed_data:
dependency: transitive
description:
@ -252,6 +378,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0"
unicode:
dependency: transitive
description:
name: unicode
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.4"
url_launcher:
dependency: "direct main"
description:
@ -294,6 +427,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
validate:
dependency: transitive
description:
name: validate
url: "https://pub.dartlang.org"
source: hosted
version: "1.7.0"
vector_math:
dependency: transitive
description:
@ -308,6 +448,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
wkt_parser:
dependency: transitive
description:
name: wkt_parser
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.7"
xdg_directories:
dependency: transitive
description:
@ -317,4 +464,4 @@ packages:
version: "0.2.0"
sdks:
dart: ">=2.12.0 <3.0.0"
flutter: ">=1.22.0"
flutter: ">=2.0.0"

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

@ -11,6 +11,10 @@ dependencies:
sdk: flutter
url_launcher: ^6.0.3
google_fonts: ^2.0.0
flutter_markdown: ^0.6.2
flutter_map: ^0.12.0
dual_screen:
path: ../dual_screen
dev_dependencies:
flutter_test: