Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Last active November 19, 2022 13:13
Show Gist options
  • Save slightfoot/fa1253ff61af1871ee9e34142f3cbee0 to your computer and use it in GitHub Desktop.
Save slightfoot/fa1253ff61af1871ee9e34142f3cbee0 to your computer and use it in GitHub Desktop.
Shopify example app - by Simon Lightfoot - 05/11/2022
// MIT License
//
// Copyright (c) 2022 Simon Lightfoot
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
import 'dart:async';
import 'dart:convert' show json;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:graphql/client.dart';
void main() {
runApp(const ShopifyApp());
}
class Backend extends ChangeNotifier {
Backend._();
final _store = Store();
late AppData _appData;
User get user => _appData.user;
bool get hasUser => _appData.user != User.none;
List<Product> get products => _appData.products;
late GraphQLClient _graphQLClient;
static Future<Backend> create() async {
final backend = Backend._();
await backend._init();
// DEBUG ONLY
await Future.delayed(const Duration(milliseconds: 1500));
return backend;
}
Future<void> _init() async {
_appData = await _store.load();
_graphQLClient = GraphQLClient(
cache: GraphQLCache(),
link: HttpLink(
'https://flutter-swag-stop.myshopify.com/api/2022-10/graphql.json',
defaultHeaders: {
'X-Shopify-Storefront-Access-Token': '14c7ce6bad38633232e266337dfe0f08',
},
),
);
}
Stream<List<Product>> updateProducts() async* {
if (_appData.products.isNotEmpty) {
yield _appData.products;
}
final options = QueryOptions(
document: gql(
r'''
query MyQuery {
products(first: 10) {
edges {
node {
id
title
description
featuredImage {
width
url
height
}
priceRange {
minVariantPrice {
currencyCode
amount
}
}
}
}
}
}
''',
),
);
final result = await _graphQLClient.query(options);
if (result.hasException) {
if (_appData.products.isEmpty) {
throw result.exception!;
}
}
final products = (result.data!['products']['edges'] as List<dynamic>) //
.map((edge) => Product.fromJson((edge['node'] as Map).cast()))
.toList();
// DEBUG ONLY
await Future.delayed(const Duration(milliseconds: 3000));
yield products;
_appData = _appData.copyWith(products: products);
_store.save(_appData);
}
Future<void> login(String name) async {
_appData = _appData.copyWith(user: User(name: name));
_store.save(_appData);
// DEBUG ONLY
await Future.delayed(const Duration(milliseconds: 1500));
notifyListeners();
}
Future<void> logout() async {
_appData = _appData.copyWith(user: User.none, products: []);
_store.save(_appData);
notifyListeners();
}
}
@immutable
class ShopifyApp extends StatefulWidget {
const ShopifyApp({super.key});
static Backend backendOf(BuildContext context) {
return context.findAncestorStateOfType<_ShopifyAppState>()!.backend!;
}
@override
State<ShopifyApp> createState() => _ShopifyAppState();
}
class _ShopifyAppState extends State<ShopifyApp> {
late Future<Backend> _backendLoader;
Backend? backend;
@override
void initState() {
super.initState();
_backendLoader = Backend.create().then((value) => backend = value);
}
@override
void dispose() {
backend?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Backend>(
initialData: backend,
future: _backendLoader,
builder: (BuildContext context, AsyncSnapshot<Backend> snapshot) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.light(),
home: Builder(
builder: (BuildContext context) {
if (snapshot.connectionState != ConnectionState.done) {
return const SplashScreen();
} else {
final backend = snapshot.requireData;
return AnimatedBuilder(
animation: backend,
builder: (BuildContext context, Widget? child) {
if (backend.hasUser) {
return const HomeScreen();
} else {
return const LoginScreen();
}
},
);
}
},
),
);
},
);
}
}
@immutable
class SplashScreen extends StatelessWidget {
const SplashScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: Colors.deepOrangeAccent.shade100,
child: Center(
child: Text(
'My Shop',
style: theme.textTheme.titleLarge,
),
),
);
}
}
@immutable
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> with BackendMixin {
Future<void>? _progress;
final _formKey = GlobalKey<FormState>();
late String _name;
void _onLoginPressed() {
final form = _formKey.currentState!;
if (!form.validate()) {
return;
}
form.save();
setState(() {
_progress = backend.login(_name);
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: Colors.blueAccent.shade100,
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Login',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 48.0),
FutureBuilder(
future: _progress,
builder: (BuildContext context, AsyncSnapshot<dynamic> snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
return IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 300.0),
child: TextFormField(
decoration: const InputDecoration(
label: Text('Username'),
),
onSaved: (String? value) => _name = value ?? '',
validator: (String? value) {
return (value?.isEmpty ?? true) ? 'Must enter username' : null;
},
),
),
const SizedBox(height: 16.0),
ElevatedButton(
onPressed: _onLoginPressed,
child: const Text('Login'),
),
const SizedBox(height: 32.0),
if (snapshot.hasError) //
Text(
snapshot.error.toString(),
style: theme.textTheme.bodyMedium!.copyWith(color: theme.errorColor),
)
],
),
);
},
),
],
),
),
);
}
}
@immutable
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> with BackendMixin {
late Stream<List<Product>> _products;
@override
void initState() {
super.initState();
_products = backend.updateProducts();
}
void _onLogoutPressed() {
backend.logout();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text('Home for ${backend.user.name}'),
actions: [
IconButton(
onPressed: _onLogoutPressed,
icon: const Icon(Icons.exit_to_app),
),
],
),
body: StreamBuilder<List<Product>>(
initialData: backend.products,
stream: _products,
builder: (BuildContext context, AsyncSnapshot<List<Product>> snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final products = snapshot.requireData;
return ListView.separated(
itemCount: products.length,
itemBuilder: (BuildContext context, int index) {
final product = products[index];
return ListTile(
onTap: () {
Navigator.of(context).push(DetailsScreen.route(product));
},
title: Text(
'${product.title}: ${product.price}',
style: theme.textTheme.titleSmall,
),
subtitle: Text(product.description),
trailing: product.featuredImage != FeaturedImage.none
? AspectRatio(
aspectRatio: product.featuredImage.width / product.featuredImage.height,
child: Image.network(
product.featuredImage.url,
),
)
: null,
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(height: 1.0);
},
);
},
),
);
}
}
@immutable
class DetailsScreen extends StatelessWidget {
static Route<void> route(Product product) {
return MaterialPageRoute(
builder: (BuildContext context) {
return DetailsScreen(product: product);
},
);
}
const DetailsScreen({
super.key,
required this.product,
});
final Product product;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('${product.title} for ${product.price}')),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(product.description),
if (product.featuredImage != FeaturedImage.none) //
Image.network(product.featuredImage.url),
],
),
),
);
}
}
mixin BackendMixin<T extends StatefulWidget> on State<T> {
late Backend backend;
@override
void initState() {
super.initState();
backend = ShopifyApp.backendOf(context);
}
}
class Store {
Store() : _file = File('appdata.json');
final File _file;
Timer? _timer;
Future<AppData> load() async {
try {
return AppData.fromJson(json.decode(await _file.readAsString()));
}
catch(e) {
await _file.delete();
return AppData.empty();
}
}
void save(AppData? data) {
_timer?.cancel();
_timer = Timer(const Duration(milliseconds: 500), () {
if (data != null) {
_file.writeAsStringSync(json.encode(data.toJson()));
} else {
_file.deleteSync();
}
});
}
}
class User {
const User({required this.name});
final String name;
static const none = User(name: 'none');
factory User.fromJson(Map<String, dynamic> json) {
return User(name: json['name']);
}
Map<String, dynamic> toJson() => {'name': name};
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is User && runtimeType == other.runtimeType && name == other.name;
@override
int get hashCode => name.hashCode;
@override
String toString() => '${describeIdentity(this)}{name: $name}';
}
class AppData {
const AppData({
required this.user,
required this.products,
});
AppData.empty()
: user = User.none,
products = const [];
final User user;
final List<Product> products;
AppData copyWith({
User? user,
List<Product>? products,
}) {
return AppData(
user: user ?? this.user,
products: products ?? this.products,
);
}
factory AppData.fromJson(Map<String, dynamic> json) {
return AppData(
user: User.fromJson(json['user']),
products: (json['products'] as List) //
.map((el) => Product.fromJson((el as Map).cast()))
.toList(),
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'user': user,
'products': products,
};
}
}
class Product {
const Product({
required this.id,
required this.title,
required this.description,
required this.featuredImage,
required this.price,
});
final String id;
final String title;
final String description;
final FeaturedImage featuredImage;
final String price;
factory Product.fromJson(Map<String, dynamic> json) {
if (json['price'] == null) {
json['price'] = '${json['priceRange']['minVariantPrice']['amount']} '
'${json['priceRange']['minVariantPrice']['currencyCode']}';
}
return Product(
id: json['id'] as String? ?? '',
title: json['title'] as String? ?? '',
description: json['description'] as String? ?? '',
featuredImage: json['featuredImage'] != null //
? FeaturedImage.fromJson((json['featuredImage'] as Map).cast())
: FeaturedImage.none,
price: json['price'] as String,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'title': title,
'description': description,
'featuredImage': featuredImage != FeaturedImage.none //
? featuredImage.toJson()
: null,
'price': price,
};
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Product && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
@override
String toString() {
return '${describeIdentity(this)}{id: $id, title: $title, description: $description, featuredImage: $featuredImage, price: $price}';
}
}
class FeaturedImage {
const FeaturedImage({
required this.url,
required this.width,
required this.height,
});
factory FeaturedImage.fromJson(Map<String, dynamic> json) {
return FeaturedImage(
url: json['url'] as String? ?? '',
width: json['width'] as int? ?? 0,
height: json['height'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'url': url,
'width': width,
'height': height,
};
}
static const none = FeaturedImage(url: '', width: 0, height: 0);
final String url;
final int width;
final int height;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is FeaturedImage &&
runtimeType == other.runtimeType &&
url == other.url &&
width == other.width &&
height == other.height;
@override
int get hashCode => url.hashCode ^ width.hashCode ^ height.hashCode;
@override
String toString() {
return '${describeIdentity(this)}{url: $url, width: $width, height: $height}';
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment