Menu
CoddyTech

Cửa hàng: Thanh toán

Thuộc phần Dự án trong hành trình Flutter của Coddy. Bài học 27 trên 36.

Bước cuối cùng của mọi cửa hàng: thanh toán. Ở đây, Checkout cảm ơn bạn, làm trống giỏ hàng rồi đưa bạn quay lại cửa hàng. (Cửa hàng thật sẽ gửi đơn hàng đến máy chủ trước; bạn đã thấy cách làm trong phần mạng.)

Thứ tự rất quan trọng. Đọc tổng tiền trước khi xóa giỏ hàng, nếu không lời cảm ơn sẽ ghi $0.00:

void _checkout(BuildContext context) {
  final cart = context.read<CartModel>();
  final total = money.format(cart.total);
  cart.clear();
  showDialog(context: context, builder: (context) => AlertDialog(...));
}

showDialog mở một AlertDialog trên màn hình, với icon, title, một dòng content và actions. Nút của hộp thoại nên đóng hộp thoại và giỏ hàng cùng lúc. Navigator.popUntil(context, (route) => route.isFirst) đóng các màn hình cho đến khi màn hình đầu tiên, tức cửa hàng, nằm trên cùng.

Nút Checkout trên giỏ hàng trống chẳng có ý nghĩa gì, vì vậy hãy đặt onPressed: null khi đó: Flutter làm mờ nút không có onPressed.

Đây là bài học cuối cùng về cửa hàng. Các bước kiểm tra mô phỏng việc mua sắm thực tế: xem sản phẩm, thêm sản phẩm, thay đổi giỏ hàng rồi thanh toán.

challenge icon

Thử thách

Khó

Xem các yêu cầu sau:

  • thêm phương thức clear() vào CartModel
  • bên dưới tổng tiền, cách 12 pixel, thêm nút FilledButton toàn chiều rộng có nhãn Checkout, chuyển sang màu xám khi giỏ hàng trống
  • nút này đọc tổng tiền, xóa giỏ hàng và hiển thị AlertDialog: biểu tượng Icons.check_circle màu xanh lá, kích thước 48, tiêu đề Thank you!, dòng Your order of $57.50 is on its way. (với tổng tiền thực tế) và nút TextButton Back to shop để quay lại cửa hàng

Tự làm thử

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CartModel(),
      child: const MyApp(),
    ),
  );
}

final money = NumberFormat.currency(locale: 'en_US', symbol: '\$');

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
      ),
      home: const ShopScreen(),
    );
  }
}

class Product {
  const Product({
    required this.name,
    required this.price,
    required this.icon,
    required this.color,
    required this.description,
  });

  final String name;
  final double price;
  final IconData icon;
  final MaterialColor color;
  final String description;
}

const List<Product> products = [
  Product(
    name: 'Headphones',
    price: 59,
    icon: Icons.headphones,
    color: Colors.indigo,
    description: 'Wireless headphones with 30 hours of battery.',
  ),
  Product(
    name: 'Watch',
    price: 129,
    icon: Icons.watch,
    color: Colors.teal,
    description: 'A smart watch that counts your steps.',
  ),
  Product(
    name: 'Backpack',
    price: 45.5,
    icon: Icons.backpack,
    color: Colors.orange,
    description: 'Room for a laptop, a lunch and a jacket.',
  ),
  Product(
    name: 'Mug',
    price: 12,
    icon: Icons.coffee,
    color: Colors.brown,
    description: 'Holds a big coffee and keeps it warm.',
  ),
  Product(
    name: 'Camera',
    price: 1299,
    icon: Icons.photo_camera,
    color: Colors.blueGrey,
    description: 'A mirrorless camera for sharp photos.',
  ),
  Product(
    name: 'Sneakers',
    price: 89.99,
    icon: Icons.directions_run,
    color: Colors.red,
    description: 'Light running shoes for every day.',
  ),
];

class CartModel extends ChangeNotifier {
  final Map<Product, int> _quantities = {};

  List<Product> get items => _quantities.keys.toList();
  int quantityOf(Product product) => _quantities[product] ?? 0;
  int get count => _quantities.values.fold(0, (sum, quantity) => sum + quantity);
  double get total => items.fold(0, (sum, product) => sum + product.price * quantityOf(product));

  void add(Product product) {
    _quantities[product] = quantityOf(product) + 1;
    notifyListeners();
  }

  void remove(Product product) {
    if (quantityOf(product) > 1) {
      _quantities[product] = quantityOf(product) - 1;
    } else {
      _quantities.remove(product);
    }
    notifyListeners();
  }
}

class ShopScreen extends StatelessWidget {
  const ShopScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final count = context.watch<CartModel>().count;
    return Scaffold(
      appBar: AppBar(
        title: const Text('Coddy Shop'),
        actions: [
          Padding(
            padding: const EdgeInsets.only(right: 8),
            child: IconButton(
              onPressed: () {
                Navigator.push(
                  context,
                  MaterialPageRoute(builder: (context) => const CartScreen()),
                );
              },
              icon: Badge(
                isLabelVisible: count > 0,
                label: Text('$count'),
                child: const Icon(Icons.shopping_cart),
              ),
            ),
          ),
        ],
      ),
      body: GridView.count(
        crossAxisCount: 2,
        padding: const EdgeInsets.all(16),
        mainAxisSpacing: 12,
        crossAxisSpacing: 12,
        childAspectRatio: 0.8,
        children: [
          for (Product product in products) ProductCard(product: product),
        ],
      ),
    );
  }
}

class ProductCard extends StatelessWidget {
  const ProductCard({super.key, required this.product});

  final Product product;

  @override
  Widget build(BuildContext context) {
    return Card(
      clipBehavior: Clip.antiAlias,
      child: InkWell(
        onTap: () {
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => ProductScreen(product: product),
            ),
          );
        },
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Expanded(
              child: Container(
                width: double.infinity,
                alignment: Alignment.center,
                color: product.color.shade50,
                child: Icon(product.icon, size: 56, color: product.color),
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(12),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    product.name,
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                  const SizedBox(height: 4),
                  Text(
                    money.format(product.price),
                    style: TextStyle(
                      fontWeight: FontWeight.bold,
                      color: Theme.of(context).colorScheme.primary,
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class ProductScreen extends StatelessWidget {
  const ProductScreen({super.key, required this.product});

  final Product product;

  @override
  Widget build(BuildContext context) {
    final textTheme = Theme.of(context).textTheme;
    return Scaffold(
      appBar: AppBar(title: Text(product.name)),
      body: ListView(
        padding: const EdgeInsets.all(24),
        children: [
          Container(
            height: 240,
            alignment: Alignment.center,
            decoration: BoxDecoration(
              color: product.color.shade50,
              borderRadius: BorderRadius.circular(24),
            ),
            child: Icon(product.icon, size: 120, color: product.color),
          ),
          const SizedBox(height: 24),
          Text(
            money.format(product.price),
            style: textTheme.headlineSmall?.copyWith(
              fontWeight: FontWeight.bold,
              color: Theme.of(context).colorScheme.primary,
            ),
          ),
          const SizedBox(height: 12),
          Text(product.description, style: textTheme.bodyLarge),
          const SizedBox(height: 32),
          FilledButton.icon(
            onPressed: () {
              context.read<CartModel>().add(product);
              ScaffoldMessenger.of(context).hideCurrentSnackBar();
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text('Added ${product.name} to your cart')),
              );
            },
            icon: const Icon(Icons.add_shopping_cart),
            label: const Text('Add to cart'),
          ),
        ],
      ),
    );
  }
}

class CartScreen extends StatelessWidget {
  const CartScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final cart = context.watch<CartModel>();
    return Scaffold(
      appBar: AppBar(title: const Text('Your Cart')),
      bottomNavigationBar: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            children: [
              Text('Total', style: Theme.of(context).textTheme.titleLarge),
              const Spacer(),
              Text(
                money.format(cart.total),
                style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
              ),
            ],
          ),
        ),
      ),
      body: cart.items.isEmpty
          ? Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Icon(
                    Icons.shopping_cart_outlined,
                    size: 64,
                    color: Theme.of(context).colorScheme.outline,
                  ),
                  const SizedBox(height: 12),
                  const Text('Your cart is empty'),
                ],
              ),
            )
          : ListView(
              children: [
                for (Product product in cart.items)
                  ListTile(
                    leading: CircleAvatar(
                      backgroundColor: product.color.shade50,
                      child: Icon(product.icon, color: product.color),
                    ),
                    title: Text(product.name),
                    subtitle: Text('${cart.quantityOf(product)} × ${money.format(product.price)}'),
                    trailing: Row(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        Text(money.format(product.price * cart.quantityOf(product))),
                        IconButton(
                          onPressed: () => cart.remove(product),
                          icon: const Icon(Icons.remove_circle_outline),
                        ),
                      ],
                    ),
                  ),
              ],
            ),
    );
  }
}
quiz iconTự kiểm tra

Bài học này có một bài trắc nghiệm ngắn. Bắt đầu bài học để trả lời và theo dõi tiến độ.

Tất cả bài học trong Dự án

Tự luyện tập: Trình biên dịch Flutter online