[Flutter] 플러터로 쇼핑몰 앱 만들기 [3]

     

 

구현 기능 

- 회원가입 / 로그인

- 상품 조회

- 장바구니

 

개발환경

- 윈도우 10, IntelliJ

 

github:

전체 - tkdlek11112/shopingmall_flutter: simple shopingmall by flutter (github.com)

이번 포스팅 완료 브랜치 - tkdlek11112/shopingmall_flutter: simple shopingmall by flutter (github.com)

 

들어가기 전에

지난 시간에는 파이어 베이스를 이용해서 회원가입/로그인 기능을 만들었습니다. 파이어 베이스의 강력크한 기능을 이용해 간단하고 빠르게 만들 수 있었습니다. 

 

이번 시간에는 쇼핑몰의 핵심 기능인 상품 관련된 기능들을 만들어보겠습니다. 

 

 

 

상품 조회 - 홈 탭

우리가 만든 쇼핑몰 앱은 하단 탭 4개를 가지고 있는데, 그중에서 홈 탭을 눌렀을 때 상품이 바둑판 형태로 정렬돼서 보이도록 하는 기능을 넣어보겠습니다. 

 

일단 상품 데이터가 필요하기 때문에 데이터베이스가 있어야 합니다. 파이어 베이스에서 제공하는 파이어 스토어를 사용해서 데이터베이스를 만들어보겠습니다.

 

파이어 베이스 콘솔에서 Cloud Firestore를 누릅니다. 데이터베이스 만들기를 누르고 테스트 모드에서 실행을 누르면 됩니다. 데이터베이스 위치는 asia-northeast3입니다.

 

cloud firestore

 

firesotre 화면

 

파이어 스토어에 들어가면 위처럼 빈 화면이 나오게 됩니다. 여기에 컬렉션 시작을 눌러서 데이터를 정의할 수 있습니다. 컬렉션 시작을 누르고 컬렉션 ID를 items라고 적습니다.

 

items
데이터를 입력합니다.

문서 ID는 자동 ID를 선택하면 알아서 만들어집니다. 그 아래 우리가 사용할 필드들을 정의하고 실제 값을 넣어서 데이터를 만듭니다. 파이어 스토어는 NoSQL이기 때문에 일반적으로 우리가 알고 있는 mysql과 같은 RDB와는 쪼꼼 다릅니다. 사용하는 필드는 title, brand, description, imageUrl, price, registerDate입니다. 필드 이름만 봐도 어떤 값인지 아시겠죠?

 

저장을 누르면 아래와 같이 문서가 추가된 것을 확인할 수 있습니다. mysql에서 row가 생성된 것과 동일합니다.

 

문서가 생성되었다.

비슷하게 여러 개 데이터를 만듭니다. 조회하기를 했을 때 하나만 보이면 썰렁하겠죠? 이것저것 데이터를 만들어봅시다.

 

5개 정도 만듬

5개 정도 만들었습니다. 

 

이제 파이어 스토어에 있는 데이터를 읽을 수 있도록 모델을 만들어보겠습니다.

 

// models/model_item.dart
import 'package:cloud_firestore/cloud_firestore.dart';

class Item {
  late String id;
  late String title;
  late String brand;
  late String description;
  late String imageUrl;
  late String registerDate;
  late int price;

  Item({
    required this.id,
    required this.title,
    required this.brand,
    required this.description,
    required this.imageUrl,
    required this.price,
    required this.registerDate,
  });
  
  Item.fromSnapshot(DocumentSnapshot snapshot) {
    Map<String, dynamic> data = snapshot.data() as Map<String, dynamic>;
    id = snapshot.id;
    title = data['title'];
    brand = data['brand'];
    description = data['description'];
    imageUrl = data['imageUrl'];
    price = data['price'];
    registerDate = data['registerDate'];
  }
}

 

모델에 특이한 점은 fromSnapshot이라는 메서드를 만들었다는 점입니다. 우리가 데이터를 가져올 곳이 파이어 스토어이기 때문에 파이어 베이스에서 제공하는 패키지인 documentSnapshot을 이용해서 데이터를 가져올 수 있습니다. json형태로 가져오기 위해 Map<String, dynamic>을 사용했습니다.

 

이제 실제로 DB에서 데이터를 가져오는 Provider를 만들어야 합니다.

 

// models/model_item_provider.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'model_item.dart';

class ItemProvider with ChangeNotifier {
  late CollectionReference itemsReference;
  List<Item> items = [];

  ItemProvider({reference}) {
    itemsReference = reference ?? FirebaseFirestore.instance.collection('items');
  }

  Future<void> fetchItems() async {
    items = await itemsReference.get().then( (QuerySnapshot results) {
      return results.docs.map( (DocumentSnapshot document) {
        return Item.fromSnapshot(document);
      }).toList();
    });
    notifyListeners();
  }
}

 

이번 코드는 살짝 복잡합니다. 일단 CollectionReference라는 게 처음 사용되었습니다. 요놈은 파이어 스토어에 있는 컬렉션에 대한 객체입니다. 저희는 items 컬렉션을 가져올 것이기 때문에 itemsReference라고 이름 지었습니다. 그리고 ItemProvider()라는 생성자를 사용해 처음 만들어졌을 때 인자 값인 reference 아니면 파이어 스토어에 있는 items 컬렉션을 가져오도록 초기화해놨습니다. 

 

여기서 중요한 건 컬렉션을 가져온 것이지 데이터를 가져온 것이 아니라는 점입니다. 실제로 CollectionReference 객체를 타고 들어가 보면 데이터라기보다는 path나 id값 등을 가지고 있다는 것을 알 수 있습니다. 즉 컬렉션 그 자체를 의미합니다.

 

데이터를 가져오기 위해서는 .get()을 해주면 됩니다. fetchItems를 보면 itemsReference.get()을 이용해서 데이터를 전부 가져오는 것을 볼 수 있습니다. 이때 가져온 데이터는 QuerySnapshot으로 복수형의 데이터이고 여기서 안에 하나하나 데이터들은 DocumentSnapshot 형태로 되어있습니다. 이건 저희가 item 모델에 만들어놓은 fromSnapshot 메서드를 통해 데이터를 가져올 수 있습니다.

 

이제 Provider를 만들었으니 main.dart에 해당 Provider를 등록해야 합니다.

 

// main.dart
      ...
      providers: [
        ChangeNotifierProvider(create: (_) => FirebaseAuthProvider()),
        ChangeNotifierProvider(create: (_) => ItemProvider()),
      ],
      ...

 

이제 우리가 데이터를 뿌려줄 홈 탭에 가서 작업을 마무리하도록 하겠습니다.

 

// tabs/tab_home.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shopingmall_flutter/models/model_item_provider.dart';

class TabHome extends StatelessWidget{

  @override
  Widget build(BuildContext context) {
    final itemProvider = Provider.of<ItemProvider>(context);
    return FutureBuilder(
      future: itemProvider.fetchItems(),
      builder: (context, snapshots) {
        if (itemProvider.items.length == 0) {
          return Center(
            child: CircularProgressIndicator(),
          );
        } else {
          return GridView.builder(
              gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2,
                childAspectRatio: 1 / 1.5,
              ), 
              itemCount: itemProvider.items.length,
              itemBuilder: (context, index) {
                return GridTile(
                    child: InkWell(
                      onTap: () { },
                      child: Container(
                        padding: EdgeInsets.all(10),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Image.network(itemProvider.items[index].imageUrl),
                            Text(
                              itemProvider.items[index].title,
                              style: TextStyle(fontSize: 20),
                            ),
                            Text(itemProvider.items[index].price.toString() + '원',
                            style: TextStyle(fontSize: 16, color: Colors.red),)
                          ],
                        ),
                      ),
                    )
                );
              }
          );
        }
      },
    );
  }
}

 

프로바이더에서 가져온 데이터를 gridview를 이용해 바둑판 형태로 나타냅니다. 글씨 크기나 배열 같은 것은 여러분 입맛대로 하시면 됩니다. 

 

실행하면 아래와 같이 실행됩니다.

실행화면

인터넷이 느려서 그림을 못 불러올 때도 있더라고요. 앱을 껐다 켜면 다시 나옵니다. ontap을 구현하지 않았기 때문에 눌러도 아무 일도 발생하지 않습니다. tap 했을 때 상품의 상세정보로 가도록 만들어봅시다.

 

 

 

상세 조회 화면

상품을 누르면 상세화면으로 넘어가도록 만들어보도록 하겠습니다. screen_detail.dart 파일을 만듭니다.

 

// screens/screen_detail.dart

import 'package:flutter/material.dart';

import '../models/model_item.dart';

class DetailScreen extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    final item = ModalRoute.of(context)!.settings.arguments as Item;
    return Scaffold(
      appBar: AppBar(
        title: Text(item.title),
      ),
      body: Container(
        child: ListView(
          children: [
            Image.network(item.imageUrl),
            Padding(padding: EdgeInsets.all(4)),
            Container(
              width: MediaQuery.of(context).size.width * 0.8,
              padding: EdgeInsets.all(10),
              child: Text(
                item.title,
                style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
              ),
            ),
            Container(
              width: MediaQuery.of(context).size.width * 0.8,
              padding: EdgeInsets.all(10),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        item.price.toString() + '원',
                        style: TextStyle(fontSize: 18, color: Colors.red),
                      ),
                      Text(
                        '브랜드 : ' + item.brand,
                        style: TextStyle(fontSize: 16),
                      ),
                      Text(
                        '등록일 : ' + item.registerDate,
                        style: TextStyle(fontSize: 16),
                      )
                    ],
                  ),
                  InkWell(
                    onTap: () { },
                    child: Column(
                      children: [
                        Icon(
                          Icons.add,
                          color: Colors.blue,
                        ),
                        Text(
                          'add cart',
                          style: TextStyle(color: Colors.blue),
                        )
                      ],
                    ),
                  )
                ],
              ),
            ),
            Container(
              padding: EdgeInsets.all(15),
              child: Text(item.description, style: TextStyle(fontSize: 16),),
            )
          ],
        ),
      ),
    );
  }
}

 

상세조회 화면은 Navigator를 통해서 넘어온 arguments값을 이용해서 화면을 그립니다. 상품 조회 화면에서 상세 화면으로 넘어갈 때 선택한 Items에 대한 정보를 넘기면, 상세 화면에서 넘어온 데이터를 이용해 각각의 항목을 보여줍니다. 

 

상세화면은 main에 있는 route에 넣고, 상품 조회 화면에서 ontap에 navigator를 추가합니다.

 

// main.dart
        ...
        routes: {
          '/index': (context) => IndexScreen(),
          '/login': (context) => LoginScreen(),
          '/splash': (context) => SplashScreen(),
          '/register': (context) => RegisterScreen(),
          '/detail': (context) => DetailScreen(),
        },
        ...
// tabs/tab_home.dart
                      ...
                      onTap: () {
                        Navigator.pushNamed(context, '/detail',
                            arguments: itemProvider.items[index]);
                      },
                      ...

Navigator를 사용할 때 arguments를 사용해서 선택한 item을 넘기는 것을 주목하세요~!

 

이제 앱을 실행하면 클릭했을 때 다음과 같이 상세화면으로 넘어가는 것을 확인할 수 있습니다.

 

상세화면으로 이동

 

 

검색 화면

두 번째 탭인 검색탭을 만들어보도록 하겠습니다. 검색이 동작하는 기본 개념은 Provider를 통해 가져온 전체 데이터에서 해당 키워드가 들어가 있는 데이터만 골라서 보여주는 기능입니다. 그 외에 데이터를 처리하고 화면에 보여주는 것은 동일합니다.

 

처음에 검색 탭을 위한 화면으로 tab_search.dart를 만들었는데, 검색은 탭을 사용하지 않고 전체 화면을 사용하겠습니다. screen_search.dart를 새로 만들어서 사용하는데, 그 이유는 탭으로 할 경우 검색창이 제일 위에 배치되지 않기 때문입니다.

 

가장 먼저 검색 키워드를 위한 모델을 만들어줍니다.

 

// models/model_query.dart
import 'package:flutter/material.dart';

class QueryProvider with ChangeNotifier {
  String text = '';
  
  void updateText(String newText) {
    text = newText;
    notifyListeners();
  }
}

 

검색 키워드도 Provider로 만들어줍니다.

 

이제 main에서 검색 Provider를 추가합니다.

 

// main.dart
      ...
      providers: [
        ChangeNotifierProvider(create: (_) => FirebaseAuthProvider()),
        ChangeNotifierProvider(create: (_) => ItemProvider()),
        ChangeNotifierProvider(create: (_) => QueryProvider()),
      ],
      ...

 

이제 ItemProvider에 검색을 할 수 있는 기능을 추가합니다.

 

// models/model_item_provider.dart

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'model_item.dart';

class ItemProvider with ChangeNotifier {
  late CollectionReference itemsReference;
  List<Item> items = [];
  List<Item> searchItem = [];

  ItemProvider({reference}) {
    itemsReference = reference ?? FirebaseFirestore.instance.collection('items');
  }

  Future<void> fetchItems() async {
    items = await itemsReference.get().then( (QuerySnapshot results) {
      return results.docs.map( (DocumentSnapshot document) {
        return Item.fromSnapshot(document);
      }).toList();
    });
    notifyListeners();
  }

  Future<void> search(String query) async {
    searchItem = [];
    if (query.length == 0) {
      return;
    }
    for (Item item in items) {
      if (item.title.contains(query)) {
        searchItem.add(item);
      }
    }
    notifyListeners();
  }
}

 

search 메서드의 코드를 보면 아시겠지만 이미 가져온 items에 있는 값들을 for문을 돌면서 해당 키워드를 찾아서 결과를 보여주는 단순한 구조입니다. 실제 상용화된 앱에서는 따로 검색엔진을 사용하겠지만 저희는 간단히 만들기 위해서 간단한 로직을 사용했습니다.

 

이제 검색 화면을 만들어봅시다.

 

// screens/screen_search.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shopingmall_flutter/models/model_item_provider.dart';
import 'package:shopingmall_flutter/models/model_query.dart';

class SearchScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final itemProvider = Provider.of<ItemProvider>(context);
    final queryProvider = Provider.of<QueryProvider>(context);
    return Scaffold(
      appBar: AppBar(
        title: Column(
          children: [
            TextField(
              onChanged: (text) {
                queryProvider.updateText(text);
              },
              autofocus: true,
              decoration: InputDecoration(
                hintText: 'search keyword',
                border: InputBorder.none,
              ),
              cursorColor: Colors.grey,
            )
          ],
        ),
        backgroundColor: Colors.white,
        iconTheme: IconThemeData(color: Colors.black),
        actions: [
          IconButton(
              onPressed: () {
                itemProvider.search(queryProvider.text);
              },
              icon: Icon(Icons.search_rounded))
        ],
      ),
      body: Column(
        children: [
          Expanded(
              child: GridView.builder(
                  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
                    crossAxisCount: 2,
                    childAspectRatio: 1 / 1.5,
                  ),
                  itemCount: itemProvider.searchItem.length,
                  itemBuilder: (context, index) {
                    return GridTile(
                        child: InkWell(
                      onTap: () {
                        Navigator.pushNamed(context, '/detail',
                            arguments: itemProvider.searchItem[index]);
                      },
                      child: Container(
                        padding: EdgeInsets.all(10),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Image.network(itemProvider.searchItem[index].imageUrl),
                            Text(
                              itemProvider.searchItem[index].title,
                              style: TextStyle(fontSize: 20),
                            ),
                            Text(
                              itemProvider.searchItem[index].price.toString() + '원',
                              style: TextStyle(fontSize: 16, color: Colors.red),
                            )
                          ],
                        ),
                      ),
                    ));
                  }))
        ],
      ),
    );
  }
}

 

AppBar에 검색할 수 있는 InputText를 넣었습니다. body는 홈 탭에 있는 코드와 유사하지만, ItemProvider에 있는 items를 사용하지 않고 searchItem을 사용하는 것이 다릅니다.

 

이제 routes에 /search를 추가하고 검색 탭을 누르면 검색 화면으로 넘어가도록 코드를 수정합니다.

 

// main.dart
        ...
        routes: {
          '/index': (context) => IndexScreen(),
          '/login': (context) => LoginScreen(),
          '/splash': (context) => SplashScreen(),
          '/register': (context) => RegisterScreen(),
          '/detail': (context) => DetailScreen(),
          '/search': (context) => SearchScreen(),
        },
        ...
// screens/screen_index.dart
        ...
        onTap: (index) {
          setState(() {
            _currentIndex = index;
          });
          if (index == 1) {
            setState(() {
              _currentIndex = 0;
            });
            Navigator.pushNamed(context, '/search');
          }
        },
        ...

screen_index.dart에서 원래는 _currentIndex에 따라 탭이 바뀌었는데, 탭이 1이라면, 즉 검색탭을 눌렀다면 /search 화면으로 넘어가도록 Navigator를 넣었습니다. 

 

이제 앱을 실행하면 검색탭을 누를 경우 검색 화면으로 넘어가게 됩니다.

 

 

 

장바구니

마지막으로 장바구니 기능입니다. 장바구니는 사용자별로 데이터베이스에 저장되어야 하기 때문에 파이어 스토어에 새로운 컬렉션을 만들어야 합니다. items 말고 carts를 추가해봅시다. 

 

carts

아직은 아무 데이터도 없습니다. 왜냐하면 carts는 앱에서 사용자가 직접 누른 items가 쌓여야 하기 때문입니다. 

 

Item 모델을 만들 때 우리는 fromSnapshot 메서드만 만들었습니다. 파이어 스토어에 있는 데이터만 가져오기 때문에 from만 만들었는데, 이제 파이어 스토어에 저장도 해야 하기 때문에 toSnapshot이라는 기능이 필요합니다.

 

// models/model_item.dart
import 'package:cloud_firestore/cloud_firestore.dart';

class Item {
  late String id;
  late String title;
  late String brand;
  late String description;
  late String imageUrl;
  late String registerDate;
  late int price;

  Item({
    required this.id,
    required this.title,
    required this.brand,
    required this.description,
    required this.imageUrl,
    required this.price,
    required this.registerDate,
  });

  Item.fromSnapshot(DocumentSnapshot snapshot) {
    Map<String, dynamic> data = snapshot.data() as Map<String, dynamic>;
    id = snapshot.id;
    title = data['title'];
    brand = data['brand'];
    description = data['description'];
    imageUrl = data['imageUrl'];
    price = data['price'];
    registerDate = data['registerDate'];
  }

  Item.fromMap(Map<String, dynamic> data) {
    id = data['id'];
    title = data['title'];
    brand = data['brand'];
    description = data['description'];
    imageUrl = data['imageUrl'];
    price = data['price'];
    registerDate = data['registerDate'];
  }

  Map<String, dynamic> toSnapshot() {
    return {
      'id': id,
      'title': title,
      'brand': brand,
      'description': description,
      'imageUrl':imageUrl,
      'price':price,
      'registerDate':registerDate
    };
  }
}

 

그리고 carts 컬렉션에 대한 모델도 만듭니다.

 

// models/model_cart.dart



import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:shopingmall_flutter/models/model_item.dart';

class CartProvider with ChangeNotifier {
  late CollectionReference cartReference;
  List<Item> cartItems = [];

  CartProvider({reference}) {
    cartReference = reference ?? FirebaseFirestore.instance.collection('carts');
  }

  Future<void> fetchCartItemsOrCreate(String uid) async {
    if (uid == ''){
      return ;
    }
    final cartSnapshot = await cartReference.doc(uid).get();
    if(cartSnapshot.exists) {
      Map<String, dynamic> cartItemsMap = cartSnapshot.data() as Map<String, dynamic>;
      List<Item> temp = [];
      for (var item in cartItemsMap['items']) {
        temp.add(Item.fromMap(item));
      }
      cartItems = temp;
      notifyListeners();
    } else {
      await cartReference.doc(uid).set({'items': []});
      notifyListeners();
    }
  }

  Future<void> addCartItem(String uid, Item item) async {
    cartItems.add(item);
    Map<String, dynamic> cartItemsMap = {
      'items': cartItems.map( (item) {
        return item.toSnapshot();
      }).toList()
    };
    await cartReference.doc(uid).set(cartItemsMap);
    notifyListeners();
  }

  Future<void> removeCartItem(String uid, Item item) async {
    cartItems.removeWhere((element) => element.id == item.id);
    Map<String, dynamic> cartItemsMap = {
      'items': cartItems.map((item) {
        return item.toSnapshot();
      }).toList()
    };

    await cartReference.doc(uid).set(cartItemsMap);
    notifyListeners();
  }

  bool isCartItemIn(Item item) {
    return cartItems.any((element) => element.id == item.id);
  }
}

카트에 대한 Provier는 총 4개의 메서드를 가지고 있는데, 전체 조회, 카트 추가, 카트 제거, 카트에 있는지 확인 이 4가지입니다. 전체 조회를 할 때 만약 해당 사용자가 카트 데이터가 없다면 생성해주게 됩니다. 전체 조회를 할 때 굳이 temp 리스트를 만들어서 다시 cartList에 넣어주는데, 이렇게 하지 않으면 cartList가 무한히 생성되게 됩니다. (초기화가 안되므로)

 

이제 main.dart에 카트 Provier까지 추가를 하고, 가장 중요한 로그인 할 때 카트 정보를 가져오는 코드를 추가해야 합니다.

 

// main.dart
      ...
      providers: [
        ChangeNotifierProvider(create: (_) => FirebaseAuthProvider()),
        ChangeNotifierProvider(create: (_) => ItemProvider()),
        ChangeNotifierProvider(create: (_) => QueryProvider()),
        ChangeNotifierProvider(create: (_) => CartProvider()),
      ],
      ...
// models/model_auth.dart

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

enum AuthStatus { registerSuccess, registerFail, loginSuccess, loginFail }

class FirebaseAuthProvider with ChangeNotifier {
  FirebaseAuth authClient;
  User? user;

  FirebaseAuthProvider({auth}) : authClient = auth ?? FirebaseAuth.instance;

  Future<AuthStatus> registerWithEmail(String email, String password) async {
    try {
      UserCredential credential = await authClient
          .createUserWithEmailAndPassword(email: email, password: password);
      return AuthStatus.registerSuccess;
    } catch (e) {
      return AuthStatus.registerFail;
    }
  }

  Future<AuthStatus> loginWithEmail(String email, String password) async {
    try {
      await authClient
          .signInWithEmailAndPassword(email: email, password: password)
          .then((credential) async {
        user = credential.user;
        SharedPreferences prefs = await SharedPreferences.getInstance();
        prefs.setBool('isLogin', true);
        prefs.setString('email', email);
        prefs.setString('password', password);
        prefs.setString('uid', user!.uid);
      });
      return AuthStatus.loginSuccess;
    } catch (e) {
      return AuthStatus.loginFail;
    }
  }

  Future<void> logout() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    prefs.setBool('isLogin', false);
    prefs.setString('email', '');
    prefs.setString('password', '');
    prefs.setString('uid', '');
    user = null;
    await authClient.signOut();
  }
}

 

model_auth.dart에서 prefs에 uid를 저장해야 합니다. 카트는 사용자의 공유 id인 uid기준으로 생성되기 때문이죠. 카트에 4가지 메서드를 사용하기 위해서는 uid가 필수입니다.

 

그리고 스플래쉬 화면에서 로그인 체크를 할 때 카트 정보를 불러오도록 추가합니다.

 

  // screens/screen_splash.dart
  ...
  Future<bool> checkLogin() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    final cartProvider = Provider.of<CartProvider>(context, listen: false);
    bool isLogin = prefs.getBool('isLogin') ?? false;
    String uid = prefs.getString('uid') ?? '';
    cartProvider.fetchCartItemsOrCreate(uid);
    return isLogin;
  }
  ...

 

이제 사용자가 로그인하면 uid를 가지고 카트 정보를 읽어오거나 없다면 생성할 것입니다.

 

이제 카트 탭이 어떻게 보이는지 tab_cart.dart를 완성합니다.

 

// tabs/tab_cart.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shopingmall_flutter/models/model_cart.dart';


class TabCart extends StatelessWidget{
  late String uid = '';

  Future<void> getUid() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    uid = prefs.getString('uid') ?? '';
  }

  @override
  Widget build(BuildContext context)  {
    final cartProvider = Provider.of<CartProvider>(context);
    getUid();
    return FutureBuilder(
      future: cartProvider.fetchCartItemsOrCreate(uid),
      builder: (context, snapshot) {
        if (cartProvider.cartItems.length == 0) {
          return Center(
            child: CircularProgressIndicator(),
          );
        } else {
          return ListView.builder(
            itemCount: cartProvider.cartItems.length,
            itemBuilder: (context, index) {
              return ListTile(
                onTap: () {
                  Navigator.pushNamed(context, '/detail', arguments: cartProvider.cartItems[index]);
                },
                title: Text(cartProvider.cartItems[index].title),
                subtitle: Text(cartProvider.cartItems[index].price.toString()),
                leading: Image.network(cartProvider.cartItems[index].imageUrl),
                trailing: InkWell(
                  onTap: () {
                    cartProvider.removeCartItem(uid, cartProvider.cartItems[index]);
                  },
                  child: Icon(Icons.delete),
                ),
              );
            },
          );
        }
      },
    );
  }
}

간단하게 리스트 형태로 보입니다. 

 

카트에 추가할 수 있도록 상세화면도 바꿉니다.

 

// screens/screen_detail.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shopingmall_flutter/models/model_cart.dart';

import '../models/model_item.dart';

class DetailScreen extends StatelessWidget {
  late String uid = '';

  Future<void> getUid() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    uid = prefs.getString('uid') ?? '';
  }

  @override
  Widget build(BuildContext context) {
    final item = ModalRoute.of(context)!.settings.arguments as Item;
    final cartProvider = Provider.of<CartProvider>(context);
    getUid();

    return Scaffold(
      appBar: AppBar(
        title: Text(item.title),
      ),
      body: Container(
        child: ListView(
          children: [
            Image.network(item.imageUrl),
            Padding(padding: EdgeInsets.all(4)),
            Container(
              width: MediaQuery.of(context).size.width * 0.8,
              padding: EdgeInsets.all(10),
              child: Text(
                item.title,
                style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
              ),
            ),
            Container(
              width: MediaQuery.of(context).size.width * 0.8,
              padding: EdgeInsets.all(10),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        item.price.toString() + '원',
                        style: TextStyle(fontSize: 18, color: Colors.red),
                      ),
                      Text(
                        '브랜드 : ' + item.brand,
                        style: TextStyle(fontSize: 16),
                      ),
                      Text(
                        '등록일 : ' + item.registerDate,
                        style: TextStyle(fontSize: 16),
                      )
                    ],
                  ),
                  cartProvider.isCartItemIn(item) ? Icon(Icons.check, color: Colors.blue)
                  : InkWell(
                    onTap: () {
                      print(uid);
                      cartProvider.addCartItem(uid, item);
                    },
                    child: Column(
                      children: [
                        Icon(
                          Icons.add,
                          color: Colors.blue,
                        ),
                        Text(
                          'add cart',
                          style: TextStyle(color: Colors.blue),
                        )
                      ],
                    ),
                  )
                ],
              ),
            ),
            Container(
              padding: EdgeInsets.all(15),
              child: Text(item.description, style: TextStyle(fontSize: 16),),
            )
          ],
        ),
      ),
    );
  }
}

 

uid를 얻기 위해 getUid 메서드를 만들어서 사용하는 걸 참고하세요. 이게 Provider로 만들면 깔끔한데, 그냥 전역 변수에서 가져오도록 만들었습니다. 

 

자 이제 실행을 해볼까요? 이번에 테스트할 때는 로그아웃까지 완전히 하고 다시 로그인해야 정상적으로 동작됩니다. 카트 정보를 만들어야 하기 때문이죠.

 

카트

 

 

마무리하며

드디어 쇼핑몰 만들기가 끝났습니다. 백엔드를 파이어 베이스를 사용했기 때문에 생각보다 빨리 끝났네요. 아마 상품 부분은 생각보다 로직이 어려우셨을 겁니다. 중간중간에 모르는 것도 많이 나오고 데이터가 어떻게 흘러가는지 집중하지 않으면 놓치게 됩니다. 하지만 코드를 잘 따라가다 보면 금방 이해하실 수 있을 거라고 생각됩니다.

 

앱은 다 만들었지만 아직 미미한 버그들이 많을 겁니다. 예를 들어 카트에 상품이 없으면 프로그레스 바가 무한으로 돈다던지... 이런 것들은 여러분이 스스로 고쳐보세요! ㅎㅎ 

 

최근에는 Provider보다는 Podriver를 사용하는 게 대세인 것 같다고 생각되기는 하는데, 플러터 앱을 만드는 기본적인 부분에 대해서 많이 아셨을 거라 생각됩니다. 이번 앱 만드는 것을 발판 삼아 더 멋진 앱을 만드시길 바랍니다. 감사합니다 :)

 

 

 

반응형

댓글

Designed by JB FACTORY