Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions experimental/desktop_photo_search/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/

# IntelliJ related
*.iml
*.ipr
*.iws
.idea/

# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/

# Flutter/Dart/Pub related
**/doc/api/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/

# Web related
lib/generated_plugin_registrant.dart

# Exceptions to above rules.
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
10 changes: 10 additions & 0 deletions experimental/desktop_photo_search/.metadata
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.

version:
revision: 7caef218b55acac803aa67e7393e97f75ec70c5c
channel: master

project_type: app
56 changes: 56 additions & 0 deletions experimental/desktop_photo_search/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Photo Search app

This macOS application enables you to search [Unsplash](https://unsplash.com/)
for photographs that interest you. To use it, you need to add an
**Access Key** from [Unsplash API](https://unsplash.com/developers) to
[unsplash_access_key.dart](lib/unsplash_access_key.dart).

## A quick tour of the code

This Flutter project builds a desktop application. It utilises the following
desktop specific plugins:

- [file_choser] to enable the application user to select where to save a photo
from the Unsplash API.
- [menubar] for exposing Image Search functionality through the menu bar.
- [url_launcher_fde] and [url_launcher] plugin, which are used to open external links.

The Unsplash API client entry point is in the [Unsplash] class, and is built
atop [http], [built_value] and [built_collection] for JSON Rest API access.

The [DataTreeNode] and widget family, along with the [Split] widget capture
desktop file explorer master/detail view idioms.

## macOS Network and File entitlements

To access the network, macOS requires applications enable the
[com.apple.security.network.client entitlement][macOS-client]. For this
sample, this entitlement is required to access the Unsplash API.

Likewise, to save a Photo to the local file system using the `file_choser` plugin requires the
[com.apple.security.files.user-selected.read-write entitlement][macOS-read-write].

Please see [macOS Signing and Security][macOS-security] for more detail.

## Flutter Desktop is not in Flutter Stable Release Channel

This sample is an initial preview, intended to enable developers to preview what is
under development. As such, it is currently only available for use on the `master` channel
of Flutter. Please see [Flutter build release channels][flutter_channels] for more detail,
and how to switch between Flutter release channels.

[DataTreeNode]: lib/src/widgets/data_tree.dart
[Split]: lib/src/widgets/split.dart
[Unsplash]: lib/src/unsplash/unsplash.dart

[built_collection]: https://pub.dev/packages/built_collection
[built_value]: https://pub.dev/packages/built_value
[file_choser]: https://github.com/google/flutter-desktop-embedding/tree/master/plugins/file_chooser
[flutter_channels]: https://github.com/flutter/flutter/wiki/Flutter-build-release-channels
[http]: https://pub.dev/packages/http
[macOS-client]: https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_network_client
[macOS-read-write]: https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_files_user-selected_read-write
[macOS-security]: https://github.com/google/flutter-desktop-embedding/blob/master/macOS-Security.md
[menubar]: https://github.com/google/flutter-desktop-embedding/tree/master/plugins/menubar
[url_launcher]: https://pub.dev/packages/url_launcher
[url_launcher_fde]: https://github.com/google/flutter-desktop-embedding/tree/master/plugins/flutter_plugins/url_launcher_fde
30 changes: 30 additions & 0 deletions experimental/desktop_photo_search/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
include: package:pedantic/analysis_options.1.8.0.yaml

analyzer:
strong-mode:
implicit-casts: false
implicit-dynamic: false

linter:
rules:
- avoid_types_on_closure_parameters
- avoid_void_async
- await_only_futures
- camel_case_types
- cancel_subscriptions
- close_sinks
- constant_identifier_names
- control_flow_in_finally
- empty_statements
- hash_and_equals
- implementation_imports
- non_constant_identifier_names
- package_api_docs
- package_names
- package_prefixed_library_names
- test_types_in_equals
- throw_in_finally
- unnecessary_brace_in_string_interps
- unnecessary_getters_setters
- unnecessary_new
- unnecessary_statements
15 changes: 15 additions & 0 deletions experimental/desktop_photo_search/build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
targets:
$default:
builders:
built_value_generator|built_value:
enabled: true

builders:
built_value:
target: ":built_value_generator"
import: "package:built_value_generator/builder.dart"
builder_factories: ["builtValue"]
build_extensions: {".dart": [".built_value.g.part"]}
auto_apply: dependents
build_to: cache
applies_builders: ["source_gen|combining_builder"]
122 changes: 122 additions & 0 deletions experimental/desktop_photo_search/lib/main.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright 2019 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:file_chooser/file_chooser.dart' as file_choser;
import 'package:logging/logging.dart';
import 'package:menubar/menubar.dart' as menubar;
import 'package:meta/meta.dart';
import 'package:provider/provider.dart';

import 'src/model/photo_search_model.dart';
import 'src/unsplash/unsplash.dart';
import 'src/widgets/data_tree.dart';
import 'src/widgets/photo_details.dart';
import 'src/widgets/photo_search_dialog.dart';
import 'src/widgets/split.dart';
import 'unsplash_access_key.dart';

void main() {
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((rec) {
// ignore: avoid_print
print('${rec.loggerName} ${rec.level.name}: ${rec.time}: ${rec.message}');
});

if (unsplashAccessKey.isEmpty) {
Logger('main').severe('Unsplash Access Key is required. '
'Please add to `lib/unsplash_access_key.dart`.');
exit(1);
}

runApp(
ChangeNotifierProvider<PhotoSearchModel>(
create: (context) => PhotoSearchModel(
Unsplash(accessKey: unsplashAccessKey),
),
child: UnsplashSearchApp(),
),
);
}

class UnsplashSearchApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Photo Search',
theme: ThemeData(
primarySwatch: Colors.orange,
),
home: const UnsplashHomePage(title: 'Photo Search'),
);
}
}

class UnsplashHomePage extends StatelessWidget {
const UnsplashHomePage({@required this.title});
final String title;

@override
Widget build(BuildContext context) {
final photoSearchModel = Provider.of<PhotoSearchModel>(context);
menubar.setApplicationMenu([
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something that could/should be moved out of the build method? The fact that the widget's being built doesn't necessarily imply it's actively on the screen.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The menu action has an implicit linkage to this window through the callback's construction of a dialog in the window. The only way I can see to make this work is for this code to be run during build, so that it has access to the build context for showDialog. Happy to take guidance if my understanding of this constraint is incorrect.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, the need for a context weirds things up. I was mostly operating on my "build methods should avoid side effects wherever possible" instincts.

This reminds me of a situation I went to Hans for advice on: I wanted a network refresh for some data to be kicked off whenever the app navigated to a widget (DetailsScreen, I think it was). I had the update call initiating in a build method, but IIRC Hans suggested having my own Route object to do it.

At the moment, I don't have any real advice to offer, so stick with what you've got. I'm curious now, though, and I may ask Hans about it next week. 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fun part is that the menu will change depending on which window is selected for input in the user's window stack, which is independent of the repaint cycle.

menubar.Submenu(label: 'Search', children: [
menubar.MenuItem(
label: 'Search ...',
onClicked: () {
showDialog<void>(
context: context,
builder: (context) =>
PhotoSearchDialog(photoSearchModel.addSearch),
);
},
),
])
]);

return Scaffold(
appBar: AppBar(
title: Text(title),
),
body: photoSearchModel.entries.isNotEmpty
? Split(
axis: Axis.horizontal,
initialFirstFraction: 0.4,
firstChild: DataTree(photoSearchModel.entries),
secondChild: Center(
child: photoSearchModel.selectedPhoto != null
? PhotoDetails(
photo: photoSearchModel.selectedPhoto,
onPhotoSave: (photo) async {
final result = await file_choser.showSavePanel(
suggestedFileName: '${photo.id}.jpg',
allowedFileTypes: ['jpg'],
);
if (!result.canceled) {
final bytes =
await photoSearchModel.download(photo: photo);
await File(result.paths[0]).writeAsBytes(bytes);
}
},
)
: Container(),
),
)
: const Center(
child: Text('Search for Photos using the Fab button'),
),
floatingActionButton: FloatingActionButton(
onPressed: () => showDialog<void>(
context: context,
builder: (context) => PhotoSearchDialog(photoSearchModel.addSearch),
),
tooltip: 'Search for a photo',
child: Icon(Icons.search),
),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright 2019 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:typed_data';

import 'package:flutter/foundation.dart';
import 'package:meta/meta.dart';

import '../unsplash/photo.dart';
import '../unsplash/unsplash.dart';
import '../widgets/data_tree.dart' show Entry;
import 'search.dart';

class _PhotoEntry extends Entry {
_PhotoEntry(this._photo, this._model) : super('Photo by ${_photo.user.name}');

final Photo _photo;
final PhotoSearchModel _model;

@override
bool get isSelected => false;

@override
set isSelected(bool selected) {
_model._setSelectedPhoto(_photo);
}
}

class _SearchEntry extends Entry {
_SearchEntry(String query, List<Photo> photos, PhotoSearchModel model)
: super(
query,
List<Entry>.unmodifiable(
photos.map<Entry>((photo) => _PhotoEntry(photo, model)),
),
);
}

class PhotoSearchModel extends ChangeNotifier {
PhotoSearchModel(this._client);
final Unsplash _client;

List<Entry> get entries => List.unmodifiable(_entries);
final List<Entry> _entries = <Entry>[];

Photo get selectedPhoto => _selectedPhoto;
void _setSelectedPhoto(Photo photo) {
_selectedPhoto = photo;
notifyListeners();
}

Photo _selectedPhoto;

Future<void> addSearch(String query) async {
final result = await _client.searchPhotos(
query: query,
orientation: SearchPhotosOrientation.portrait,
);
final search = Search((s) {
s
..query = query
..results.addAll(result.results);
});

_entries.add(_SearchEntry(query, search.results.toList(), this));
notifyListeners();
}

Future<Uint8List> download({@required Photo photo}) =>
_client.download(photo);
}
36 changes: 36 additions & 0 deletions experimental/desktop_photo_search/lib/src/model/search.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright 2019 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:convert';

import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';

import '../serializers.dart';
import '../unsplash/photo.dart';

part 'search.g.dart';

abstract class Search implements Built<Search, SearchBuilder> {
factory Search([void Function(SearchBuilder) updates]) = _$Search;
Search._();

@BuiltValueField(wireName: 'query')
String get query;

@BuiltValueField(wireName: 'results')
BuiltList<Photo> get results;

String toJson() {
return json.encode(serializers.serializeWith(Search.serializer, this));
}

static Search fromJson(String jsonString) {
return serializers.deserializeWith(
Search.serializer, json.decode(jsonString));
}

static Serializer<Search> get serializer => _$searchSerializer;
}
Loading