Last active
November 19, 2022 13:13
-
-
Save slightfoot/fa1253ff61af1871ee9e34142f3cbee0 to your computer and use it in GitHub Desktop.
Shopify example app - by Simon Lightfoot - 05/11/2022
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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