[Flutter] 스나이퍼팩토리 6주차 주간평가 : 자체 기획앱

KWANWOO·2023년 3월 5일
1
post-thumbnail

스나이퍼팩토리 플러터 6주차 주간평가

  1. 자체 기획앱 제작
  2. 추가 내용 정리

1. 자체 기획앱 제작

공개된 API리스트 중 하나를 선택하여 직접 분석하고, 기획한 앱을 제작하고자 한다. 아래는 공개된 API의 리스트를 공유하는 Github 문서이다.
public-apis/public-apis: A collective list of free APIs - GitHub

요구사항

  • 페이지는 두 페이지 이상이어야 하며 네비게이션을 활용하여 페이지 이동이 포함될 것
  • 클래스를 작성하여 Serialization이 적용될 수 있도록 할 것
  • 적절한 애니메이션 효과를 포함할 것

기획앱 설명

공개된 API 리스트 중에서 해리포터의 캐릭터들의 데이터를 제공하는 API를 선택했다.

Harry Potter Charactes

이를 사용해 해리포터의 데이터를 받아와 캐릭터들의 인물 정보를 제공할 수 있는 앱을 제작해 보았다.

먼저 해당 API는 아래와 같이 4가지 라우트로 데이터를 제공한다.

Harry Potter API routes

해당 API를 확인하고 아래와 같이 앱을 구성해 보았다.

  • 메인 페이지는 하단 네비게이션 바를 가지고 있고, 3가지 아이템을 가지고 있다.
    • 첫 번째 스크린에서는 4개의 기숙사를 보여주고, 선택했을 때 해당 기숙사의 인물들의 정보를 보여주는 페이지로 이동한다.
    • 두 번째 스크린에서는 모든 인물의 정보를 리스트로 보여준다.
    • 두 번째 스크린에서는 필터를 적용하여, 학생들만 볼 수 있거나 스태프만 볼 수 있도록 한다.
    • 두 번째 스크린에서 인물을 클릭하면 자세한 정보를 보여주는 페이지로 이동
    • 세 번째 스크린에서는 전체 주문의 정보를 리스트로 보여준다.
  • 두 번째 스크린에서 이동한 인물의 상세 정보는 카드의 형태로 출력한다.
  • 첫 번째 페이지에서 이동한 기숙사의 인물들 정보는 페이지뷰로 출력한다.
    • 각 인물의 정보는 두 번째 페이지에서 인물 상세 정보 사용한 카드를 활용한다.

해당 기획을 토대로 작성한 앱의 소스코드 파일 구조는 아래와 같다.

lib
	ㄴ model
    	ㄴ character.dart
        ㄴ house_type.dart
        ㄴ house.dart
        ㄴ spell.dart
        ㄴ wand.dart
    ㄴ page
    	ㄴ character_detail_page.dart
        ㄴ house_detail_page.dart
        ㄴ main_page.dart
    ㄴ screen
    	ㄴ character_screen.dart
        ㄴ home_screen.dart
        ㄴ spell_screen.dart
    ㄴ widget
    	ㄴ character_card.dart
        ㄴ filter_bottom_sheet.dart
        ㄴ house_card.dart
    ㄴ main.dart

코드 작성

  • pubspec.yaml
dependencies:
  animate_do: ^3.0.2
  cached_network_image: ^3.2.3
  cupertino_icons: ^1.0.2
  dio: ^5.0.0
  flutter:
    sdk: flutter
  intl: ^0.18.0

flutter:
  assets:
    - assets/images/houses.png #기숙사 로고
    - assets/images/gryffindor.png #gryffindor 로고
    - assets/images/slytherin.png #slytherin 로고
    - assets/images/ravenclaw.png #ravenclaw 로고
    - assets/images/hufflepuff.png #hufflepuff 로고
    - assets/images/background.jpg #앱 배경 이미지

pubspec.yaml에 사용할 패키지를 설치하고, 이미지의 링크를 설정했다.

  • lib/main.dart
import 'package:flutter/material.dart';
import 'package:my_app/page/main_page.dart';

void main() {
  runApp(const MyApp());
}

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

  // root Widget
  
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark(),
      home: MainPage(), // 메인 페이지 호출
    );
  }
}

main.dartMainPage를 호출한다.

  • lib/model/character.dart
class Character {
  String id; //아이디
  String name; //이름
  List<String>? alternateNames; //다른 이름들
  String species; //종
  String gender; //성별
  House? house; //기숙사
  DateTime? dateOfBirth; //생일
  int? yearOfBirth; //태어난 년도
  bool wizard; //마법사 여부
  String? ancestry; //혈통
  String? eyeColour; //눈 색상
  String? hairColour; //머리 색상
  Wand wand; //지팡이 정보
  String? patronus; //패트로누스
  bool hogwartsStudent; //학생 여부
  bool hogwartsStaff; //스태프 여부
  String? actor; //배우 이름
  List<String>? alternateActor; //다른 배우들 이름
  bool alive; //생존 여부
  String? image; //이미지 URL

  Character({
    required this.id,
    required this.name,
    required this.alternateNames,
    required this.species,
    required this.gender,
    required this.house,
    required this.dateOfBirth,
    required this.yearOfBirth,
    required this.wizard,
    required this.ancestry,
    required this.eyeColour,
    required this.hairColour,
    required this.wand,
    required this.patronus,
    required this.hogwartsStudent,
    required this.hogwartsStaff,
    required this.actor,
    required this.alternateActor,
    required this.alive,
    required this.image,
  });

  factory Character.fromMap(Map<String, dynamic> map) {
    return Character(
      id: map['id'],
      name: map['name'],
      alternateNames: map['alternate_names'] != null
          ? List<String>.from(map['alternate_names'])
          : null,
      species: map['species'],
      gender: map['gender'],
      house: map['house'] != '' ? House.fromHouseName(map['house']) : null,
      dateOfBirth: map['dateOfBirth'] != null
          ? DateFormat('dd-MM-yyyy').parse(map['dateOfBirth'])
          : null, //map['dateOfBirth'],
      yearOfBirth: map['yearOfBirth'],
      wizard: map['wizard'],
      ancestry: map['ancestry'] != '' ? map['ancestry'] : null,
      eyeColour: map['eyeColour'] != '' ? map['eyeColour'] : null,
      hairColour: map['hairColour'] != '' ? map['hairColour'] : null,
      wand: Wand.fromMap(map['wand']),
      patronus: map['patronus'] != '' ? map['patronus'] : null,
      hogwartsStudent: map['hogwartsStudent'],
      hogwartsStaff: map['hogwartsStaff'],
      actor: map['actor'] != '' ? map['actor'] : null,
      alternateActor: map['alternate_actor'] != null
          ? List<String>.from(map['alternate_actor'])
          : null,
      alive: map['alive'],
      image: map['image'] != '' ? map['image'] : null,
    );
  }
}

Character 클래스는 네트워크에서 받아오는 캐릭터의 정보들을 가진다.

  • lib/model/wand.dart
class Wand {
  String? wood; //사용된 나무 정보
  String? core; //주요 재로
  double? length; //길이

  Wand({
    required this.wood,
    required this.core,
    required this.length,
  });

  factory Wand.fromMap(Map<String, dynamic> map) {
    return Wand(
      wood: map['wood'] != '' ? map['wood'] : null,
      core: map['core'] != '' ? map['core'] : null,
      length: map['length']?.toDouble(),
    );
  }
}

Wand클래스는 가져온 인물의 정보에서 지팡이 정보를 따로 저장한다.

  • lib/model/spell.dart
class Spell {
  String id; //아이디
  String name; //주문 이름
  String description; //주문 설명

  Spell({
    required this.id,
    required this.name,
    required this.description,
  });

  factory Spell.fromMap(Map<String, dynamic> map) {
    return Spell(
      id: map['id'],
      name: map['name'],
      description: map['description'],
    );
  }
}

Spell 클래스는 주문의 정보를 가진다.

  • lib/model/house.dart
import 'package:flutter/material.dart';
import 'package:my_app/model/house_type.dart';

class House {
  HouseType houseType; //기숙사 타입
  Color? color; //기숙사 색상
  String? image; //기숙사 로고

  House({
    required this.houseType,
    required this.color,
    required this.image,
  });

  factory House.fromHouseType(HouseType houseType) {
    Color? color;
    String? image;

    if (houseType == HouseType.gryffindor) {
      color = Colors.red;
      image = 'assets/images/gryffindor.png';
    } else if (houseType == HouseType.slytherin) {
      color = Colors.green;
      image = 'assets/images/slytherin.png';
    } else if (houseType == HouseType.hufflepuff) {
      color = Colors.yellow;
      image = 'assets/images/hufflepuff.png';
    } else if (houseType == HouseType.ravenclaw) {
      color = Colors.blue;
      image = 'assets/images/ravenclaw.png';
    }

    return House(
      houseType: houseType,
      color: color,
      image: image,
    );
  }

  factory House.fromHouseName(String houseName) {
    return House.fromHouseType(HouseType.fromCode(houseName));
  }
}

House 클래스는 기숙사의 정보를 가진다. 기숙사의 타입은 enum으로 따로 작성해 사용했고, 기숙사의 타입을 입력받아, 타입에 맞는 색상과 이미지를 설정하고, House객체를 생성하는 생성자를 작성했다. 또한 기숙사 이름을 String으로 입력받아 객체를 생성할 수 있는 생성자도 만들었다.

  • lib/model/HouseType.dart
enum HouseType {
  gryffindor('Gryffindor', 'Gryffindor'),
  slytherin('Slytherin', 'Slytherin'),
  hufflepuff('Hufflepuff', 'Hufflepuff'),
  ravenclaw('Ravenclaw', 'Ravenclaw'),
  undefined('Undefined', '');

  final String code;
  final String displayName;

  const HouseType(this.code, this.displayName);

  factory HouseType.fromCode(String code) {
    return HouseType.values.firstWhere((value) => value.code == code,
        orElse: () => HouseType.undefined);
  }
}

HouseType클래스는 enum으로 5개의 타입을 가지고, HouseType.fromCode()code를 입력받아 객체를 생성한다.

  • lib/page/main_page.dart
import 'package:flutter/material.dart';
import 'package:my_app/screen/character_screen.dart';
import 'package:my_app/screen/home_screen.dart';
import 'package:my_app/screen/spell_screen.dart';
import 'package:my_app/widget/filter_bottom_sheet.dart';

class MainPage extends StatefulWidget {
  const MainPage({super.key});

  
  State<MainPage> createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
  int currentIndex = 0; //하단 네비게이션 바 인덱스
  CharacterFilter filter = CharacterFilter.all; //캐릭터 리스트 필터

  //스크린 위젯 리시트
  List<Widget> screens = [
    HomeScreen(),
    CharacterScreen(filter: CharacterFilter.all),
    SpellScreen(),
  ];

  //앱바 타이틀 리스트
  List<String> appBarTitle = ['HarryPotter', 'Characters', 'Spells'];

  
  Widget build(BuildContext context) {
    return Scaffold(
      extendBodyBehindAppBar: true,
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        elevation: 0,
        centerTitle: true,
        title: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Icon(color: Colors.yellowAccent, Icons.bolt),
            Text(appBarTitle[currentIndex]),
          ],
        ),
      ),
      body: Container(
        padding: const EdgeInsets.only(top: 80),
        decoration: const BoxDecoration(
          image: DecorationImage(
              fit: BoxFit.cover,
              colorFilter: ColorFilter.mode(
                Colors.black54,
                BlendMode.darken,
              ),
              image: AssetImage('assets/images/background.jpg')),
        ),
        child: screens[currentIndex],
      ),
      bottomNavigationBar: BottomNavigationBar(
        selectedItemColor: Colors.white,
        unselectedItemColor: Colors.white30,
        backgroundColor: Colors.black,
        currentIndex: currentIndex,
        //스크린 변경
        onTap: (value) => setState(() {
          currentIndex = value;
        }),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
          BottomNavigationBarItem(icon: Icon(Icons.face), label: 'Charactor'),
          BottomNavigationBarItem(
              icon: Icon(Icons.auto_fix_normal), label: 'Spell'),
        ],
      ),
      //캐릭터 리스트 화면에서만 출력
      floatingActionButton: currentIndex == 1
          ? FloatingActionButton(
              onPressed: () {
                //하단 시트로 필터 선택 창 열기
                showModalBottomSheet(
                  context: context,
                  builder: (context) => FilterBottomSheet(
                    filter: filter,
                    onApply: (value) => setState(() {
                      filter = value;
                      screens.replaceRange(
                          1, 2, [CharacterScreen(filter: filter)]);
                    }),
                  ),
                );
              },
              foregroundColor: Colors.white,
              backgroundColor: Colors.blueGrey,
              child: const Icon(Icons.filter_list),
            )
          : null,
    );
  }
}

MainPage는 앱의 메인 화면으로 하단 네비게이션 바에 3개의 아이템을 가지고 있다. 각각 기숙사 선택화면, 캐릭터 리스트화면, 주문 화면을 보여준다. 캐릭터 리스트 화면에서는 FAB을 출력하여 눌렀을 때 하단 시트로 필터를 선택할 수 있는 창을 띄웠다.

  • lib/page/house_detail_page.dart
import 'package:animate_do/animate_do.dart';
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:my_app/model/character.dart';
import 'package:my_app/model/house_type.dart';
import 'package:my_app/widget/character_card.dart';

class HouseDetailPage extends StatelessWidget {
  const HouseDetailPage({super.key, required this.house});

  final HouseType house; //선택한 기숙사 정보

  //기숙사에 맞는 네트워크 데이터 요청
  Future<List<Character>> readData(String house) async {
    Dio dio = Dio();
    String url = 'https://hp-api.onrender.com/api/characters/house/$house';
    var response = await dio.get(url);

    if (response.statusCode == 200) {
      List<Map<String, dynamic>> data =
          List<Map<String, dynamic>>.from(response.data);
      return data.map((e) => Character.fromMap(e)).toList();
    }
    return [];
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      extendBodyBehindAppBar: true,
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        elevation: 0,
        centerTitle: true,
        title: Text(house.displayName),
      ),
      body: Container(
        padding: const EdgeInsets.only(top: 40),
        decoration: const BoxDecoration(
          image: DecorationImage(
              fit: BoxFit.cover,
              colorFilter: ColorFilter.mode(
                Colors.black54,
                BlendMode.darken,
              ),
              image: AssetImage('assets/images/background.jpg')),
        ),
        child: FutureBuilder(
          future: readData(house.displayName), //선택한 기숙사 명을 전달하여 데이터 요청
          builder: (context, snapshot) {
            if (snapshot.connectionState == ConnectionState.waiting) {
              return const Center(
                  child: CupertinoActivityIndicator(radius: 16));
            }
            if (!snapshot.hasData) {
              return const Text('데이터가 없습니다.');
            }

            return PageView.builder(
              physics: const BouncingScrollPhysics(),
              itemCount: snapshot.data?.length ?? 0,
              itemBuilder: (context, index) {
                return ZoomIn(
                  duration: const Duration(seconds: 1),
                  //캐릭터 정보를 출력(커스텀 위젯)
                  child: CharacterCard(person: snapshot.data![index]),
                );
              },
            );
          },
        ),
      ),
    );
  }
}

HouseDetailPage는 선택한 기숙사의 정보를 전달받아 캐릭터들의 정보를 페이지뷰로 보여준다. 네트워크에 데이터를 요청하는 readData()는 기숙사의 이름을 String으로 받아 url에 더한 뒤 데이터를 요청한다.

각 캐릭터의 정보는 커스텀 위젯인 CharacterCard로 출력했다.

  • lib/page/character_detail_page.dart
import 'package:animate_do/animate_do.dart';
import 'package:flutter/material.dart';
import 'package:my_app/model/character.dart';
import 'package:my_app/widget/character_card.dart';

class CharacterDetailPage extends StatelessWidget {
  const CharacterDetailPage({super.key, required this.person});

  final Character person; //선택한 캐릭터 정보

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        decoration: const BoxDecoration(
          image: DecorationImage(
              fit: BoxFit.cover,
              colorFilter: ColorFilter.mode(
                Colors.black54,
                BlendMode.darken,
              ),
              image: AssetImage('assets/images/background.jpg')),
        ),
        child: ZoomIn(
          duration: const Duration(seconds: 1),
          //캐릭터 정보 출력
          child: CharacterCard(person: person),
        ),
      ),
    );
  }
}

CharacterDetailPageMainPage의 캐릭터 리스트 화면에서 캐릭터를 선택했을 때 상세 정보를 보여주는 페이지이다. 선택한 캐릭터 정보인 person을 전달받아 커스텀 위젯인 CharacterCard로 출력한다.

  • lib/screen/home_screen.dart
import 'package:animate_do/animate_do.dart';
import 'package:flutter/material.dart';
import 'package:my_app/model/house.dart';
import 'package:my_app/model/house_type.dart';
import 'package:my_app/page/house_detail_page.dart';
import 'package:my_app/widget/house_card.dart';

class HomeScreen extends StatelessWidget {
  HomeScreen({super.key});

  //전체 기숙사 정보
  final List<House> houses = [
    House.fromHouseType(HouseType.gryffindor),
    House.fromHouseType(HouseType.slytherin),
    House.fromHouseType(HouseType.hufflepuff),
    House.fromHouseType(HouseType.ravenclaw),
  ];

  
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.center,
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Stack(
          alignment: Alignment.center,
          children: [
            GridView.builder(
              padding: const EdgeInsets.all(16),
              shrinkWrap: true,
              physics: const NeverScrollableScrollPhysics(),
              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2,
                mainAxisSpacing: 8,
                crossAxisSpacing: 8,
              ),
              itemCount: houses.length,
              itemBuilder: (context, index) => InkWell(
                //선택한 기숙사 정보를 전달하여 기숙사 상세 페이지로 이동
                onTap: () => Navigator.push(
                  context,
                  MaterialPageRoute(
                    builder: (context) =>
                        HouseDetailPage(house: houses[index].houseType),
                  ),
                ),
                child: ZoomIn(
                  duration: const Duration(seconds: 1),
                  //각 기숙사 카드를 출력(커스텀 위젯)
                  child: HouseCard(house: houses[index]),
                ),
              ),
            ),
            Positioned(
              child: Image.asset(
                scale: 5,
                'assets/images/houses.png',
              ),
            ),
          ],
        ),
        ZoomIn(
          delay: const Duration(milliseconds: 1500),
          duration: const Duration(seconds: 1),
          child: const Chip(
            backgroundColor: Colors.blueGrey,
            padding: EdgeInsets.all(8),
            label: Text(
              style: TextStyle(
                fontWeight: FontWeight.bold,
                fontSize: 16,
              ),
              'Choose House',
            ),
          ),
        ),
      ],
    );
  }
}

HomeScreenMainPage의 첫 번째 화면으로 기숙사를 선택하여 상세 페이지로 이동할 수 있다. 4개의 기숙사 정보를 리스트에 담아 생성하고 이를 커스텀 위젯인 HouseCard로 출력한다. 선택한 기숙사의 정보를 담고, HouseDetailPage를 호출한다.

  • lib/screen/character_screen.dart
import 'package:animate_do/animate_do.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:my_app/model/character.dart';
import 'package:flutter/cupertino.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:my_app/page/character_detail_page.dart';
import 'package:my_app/widget/filter_bottom_sheet.dart';

class CharacterScreen extends StatelessWidget {
  const CharacterScreen({super.key, required this.filter});

  final CharacterFilter filter; //캐릭터 리스트 필터

  Future<List<Character>> readData(CharacterFilter filter) async {
    String job; //캐릭터들의 직급

    if (filter == CharacterFilter.students) {
      job = 'students'; //학생
    } else if (filter == CharacterFilter.staff) {
      job = 'staff'; //스태프
    } else {
      job = '';
    }

    Dio dio = Dio();
    //url에 student 또는 staff를 더해준다.(빈 스트링의 경우 all)
    String url = 'https://hp-api.onrender.com/api/characters/$job';
    var response = await dio.get(url);

    if (response.statusCode == 200) {
      List<Map<String, dynamic>> data =
          List<Map<String, dynamic>>.from(response.data);
      return data.map((e) => Character.fromMap(e)).toList();
    }
    return [];
  }

  
  Widget build(BuildContext context) {
    return Center(
      child: FutureBuilder(
        future: readData(filter),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const CupertinoActivityIndicator(radius: 16);
          }
          if (!snapshot.hasData) {
            return const Text('데이터가 없습니다.');
          }

          return Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Padding(
                padding: const EdgeInsets.only(left: 16.0),
                //현재 필터 정보
                child: Chip(
                  padding: const EdgeInsets.all(8),
                  backgroundColor: Colors.blueGrey,
                  label: Text(filter.name),
                ),
              ),
              Expanded(
                child: ListView.builder(
                  shrinkWrap: true,
                  padding: const EdgeInsets.symmetric(horizontal: 16),
                  physics: const BouncingScrollPhysics(),
                  itemCount: snapshot.data?.length ?? 0,
                  itemBuilder: (context, index) {
                    Character person = snapshot.data![index];
                    return ZoomIn(
                      duration: const Duration(seconds: 1),
                      //각 캐릭터 정보 리스트 타일
                      child: Card(
                        color: Colors.black38,
                        child: ListTile(
                          //캐릭터 상세 페이지로 이동
                          onTap: () => Navigator.push(
                            context,
                            MaterialPageRoute(
                              builder: (context) =>
                                  CharacterDetailPage(person: person),
                            ),
                          ),
                          contentPadding: const EdgeInsets.all(8),
                          // 캐릭터 이름
                          title: Text(person.name),
                          // 배우가 있는 경우 배우명 출력
                          subtitle:
                              person.actor != null ? Text(person.actor!) : null,
                          //이미지가 있는 경우 이미지 출력
                          leading: person.image != null
                              ? CachedNetworkImage(
                                  width: 50,
                                  height: 60,
                                  imageUrl: person.image!,
                                  fit: BoxFit.cover,
                                )
                              : null,
                          trailing: const Icon(
                              color: Colors.grey, Icons.navigate_next),
                        ),
                      ),
                    );
                  },
                ),
              ),
            ],
          );
        },
      ),
    );
  }
}

CharacterScreenMainPage의 두 번째 화면으로 캐릭터들의 정보를 리스트로 보여준다. MainPage에서 작성했던 FAB을 클릭하면 필터를 선택할 수 있는 하단 시트가 띄워지고, 선택한 필터에 맞는 캐릭터만 리스트에 출력할 수 있다.

캐릭터의 간략한 정보를 ListTile로 보여주기 때문에 캐릭터 이름, 배우 이름, 이미지만 띄워주고, 해당 타일을 누르면 캐릭터 상세 페이지인 CharacterDetailPage로 이동할 수 있다.

  • lib/screen/spell_screen.dart
import 'package:animate_do/animate_do.dart';
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:my_app/model/spell.dart';

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

  //전체 주문들의 데이터를 요청
  Future<List<Spell>> readData() async {
    Dio dio = Dio();
    String url = 'https://hp-api.onrender.com/api/spells';
    var response = await dio.get(url);

    if (response.statusCode == 200) {
      List<Map<String, dynamic>> data =
          List<Map<String, dynamic>>.from(response.data);
      return data.map((e) => Spell.fromMap(e)).toList();
    }
    return [];
  }

  
  Widget build(BuildContext context) {
    return Center(
      child: FutureBuilder(
        future: readData(),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const CupertinoActivityIndicator(radius: 16);
          }
          if (!snapshot.hasData) {
            return const Text('데이터가 없습니다.');
          }

          return ListView.builder(
            shrinkWrap: true,
            padding: const EdgeInsets.all(16),
            physics: const BouncingScrollPhysics(),
            itemCount: snapshot.data?.length ?? 0,
            itemBuilder: (context, index) {
              Spell spell = snapshot.data![index];
              return ZoomIn(
                duration: const Duration(seconds: 1),
                //주문의 정보를 리스트 타일로 출력
                child: Card(
                  color: Colors.black38,
                  child: ListTile(
                    contentPadding: const EdgeInsets.all(8),
                    title: Text(spell.name), //주문 이름
                    subtitle: Text(spell.description), //주문 설명
                  ),
                ),
              );
            },
          );
        },
      ),
    );
  }
}

SpellScreenMainPage의 세 번째 화면으로 모든 주문의 정보를 리스트로 보여준다.

  • lib/widget/character_card.dart
import 'package:animate_do/animate_do.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:my_app/model/character.dart';

class CharacterCard extends StatelessWidget {
  const CharacterCard({super.key, required this.person});

  final Character person; //캐릭터 정보

  
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 30, vertical: 50),
      clipBehavior: Clip.antiAlias,
      shadowColor: person.house?.color,
      elevation: 24,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
        side: const BorderSide(
          width: 2,
          color: Colors.blueGrey,
        ),
      ),
      child: Container(
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          image: DecorationImage(
            fit: BoxFit.cover,
            //카드 배경으로 캐릭터의 이미지를 설정
            image: CachedNetworkImageProvider(
              person.image != null
                  ? person.image!
                  : 'https://images.pexels.com/photos/8391510/pexels-photo-8391510.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
            ),
            colorFilter: const ColorFilter.mode(
              Colors.black54,
              BlendMode.darken,
            ),
          ),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            ZoomIn(
              delay: const Duration(milliseconds: 500),
              child: Wrap(
                alignment: WrapAlignment.spaceBetween,
                crossAxisAlignment: WrapCrossAlignment.center,
                children: [
                  Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      //캐릭터 이름
                      Text(
                        style: const TextStyle(
                          fontSize: 16,
                          fontWeight: FontWeight.bold,
                        ),
                        person.name,
                      ),
                      //배우 이름
                      if (person.actor != null)
                        Text(
                          style: const TextStyle(
                            color: Colors.grey,
                          ),
                          person.actor!,
                        ),
                    ],
                  ),
                  //직급 정보
                  if (person.hogwartsStudent)
                    const Chip(
                      backgroundColor: Colors.blueGrey,
                      label: Text('Hogwarts Student'),
                    ),
                  if (person.hogwartsStaff)
                    const Chip(
                      backgroundColor: Colors.blueGrey,
                      label: Text('Hogwarts Staff'),
                    ),
                ],
              ),
            ),
            const Expanded(child: SizedBox()),
            ZoomIn(
              delay: const Duration(seconds: 1),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text(
                    style: TextStyle(
                      fontSize: 16,
                      fontWeight: FontWeight.bold,
                    ),
                    'Profile',
                  ),
                  Text('species: ${person.species}'), //종
                  Text('gender: ${person.gender}'), //성별
                  //기숙사
                  if (person.house != null)
                    Text('house: ${person.house!.houseType.displayName}'),
                  //생일
                  if (person.dateOfBirth != null)
                    Text(
                        'birth: ${DateFormat('yyyy / MM / dd').format(person.dateOfBirth!)}'),
                  const SizedBox(height: 16),
                  const Text(
                    style: TextStyle(
                      fontSize: 16,
                      fontWeight: FontWeight.bold,
                    ),
                    'Wand',
                  ),
                  //지팡이 정보가 없는 경우
                  if (person.wand.wood == null && person.wand.core == null)
                    const Text('no wand information'),
                  //지팡이의 나무 정보
                  if (person.wand.wood != null)
                    Text('wood: ${person.wand.wood!}'),
                  //지팡이의 주 재료
                  if (person.wand.core != null)
                    Text('core: ${person.wand.core!}'),
                  //지팡이 길이
                  if (person.wand.length != null)
                    Text('length: ${person.wand.length!}'),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

CharacterCard는 캐릭터의 정보를 전달받고, 카드의 형태로 출력한다. 카드의 배경으로는 캐릭터의 이미지를 넣고, 필요한 정보들을 텍스트로 출력한다.

  • lib/widget/house_card.dart
import 'package:flutter/material.dart';
import 'package:my_app/model/house.dart';

class HouseCard extends StatelessWidget {
  const HouseCard({super.key, required this.house});

  final House house; //기숙사 정보

  
  Widget build(BuildContext context) {
    return Card(
      elevation: 16,
      shadowColor: house.color,
      clipBehavior: Clip.antiAlias,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
        side: const BorderSide(
          width: 2,
          color: Colors.blueGrey,
        ),
      ),
      child: Container(
        alignment: Alignment.center,
        decoration: BoxDecoration(
          color: house.color,
          image: DecorationImage(
            //기숙사 로고를 배경 이미지로 설정
            image: AssetImage(house.image!),
            colorFilter: const ColorFilter.mode(
              Colors.black45,
              BlendMode.darken,
            ),
            fit: BoxFit.cover,
          ),
        ),
        //기숙사 이름
        child: Text(
          style: const TextStyle(
            fontWeight: FontWeight.bold,
            fontSize: 16,
            letterSpacing: 1,
          ),
          house.houseType.displayName,
        ),
      ),
    );
  }
}

HouseCard는 기숙사의 정보를 보여주는 카드이다. 배경으로 기숙사의 로고를 설정하고 가운데에 텍스트로 기숙사의 이름을 출력한다.

  • lib/widget/filter_bottom_sheet.dart
import 'package:flutter/material.dart';

enum CharacterFilter { all, students, staff } //필터 상수 클래스

class FilterBottomSheet extends StatefulWidget {
  const FilterBottomSheet(
      {Key? key, required this.filter, required this.onApply})
      : super(key: key);
  final CharacterFilter filter; //캐릭터 리스트 필터
  final Function(CharacterFilter) onApply; //필터 적용 이벤트 핸들러

  
  State<FilterBottomSheet> createState() => _FilterBottomSheetState();
}

class _FilterBottomSheetState extends State<FilterBottomSheet> {
  //필터 적용
  onApply(CharacterFilter filter) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        backgroundColor: Colors.blueGrey,
        content: Text(
          style: const TextStyle(color: Colors.white),
          'Filter: ${filter.name}',
        ),
      ),
    );
    widget.onApply(filter); //필터 적용
    Navigator.pop(context); //페이지 뒤로 이동
  }

  
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(16),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          ListTile(
            title: const Text('All'),
            trailing: Checkbox(
              activeColor: Colors.blueGrey,
              value: widget.filter == CharacterFilter.all,
              onChanged: (value) {
                if (value == true) onApply(CharacterFilter.all);
              },
            ),
          ),
          ListTile(
            title: const Text('Students'),
            trailing: Checkbox(
              activeColor: Colors.blueGrey,
              value: widget.filter == CharacterFilter.students,
              onChanged: (value) {
                if (value == true) onApply(CharacterFilter.students);
              },
            ),
          ),
          ListTile(
            title: const Text('Staff'),
            trailing: Checkbox(
              activeColor: Colors.blueGrey,
              value: widget.filter == CharacterFilter.staff,
              onChanged: (value) {
                if (value == true) onApply(CharacterFilter.staff);
              },
            ),
          ),
        ],
      ),
    );
  }
}

FilterBottomSheet는 캐릭터 리스트의 필터를 선택할 수 있는 하단 시트이다. 현재 필터 데이터와 필터 적용 이벤트의 핸들러를 전달받고, 선택한 필터의 값을 적용하는 역할을 한다.

결과

  • MainPage의 화면들과 캐릭터 상세 페이지

  • 기숙사 선택과 기숙사의 캐릭터들 정보 페이지

2. 추가 사항

자체 기획앱 과제를 수행하면서 어려웠던 내용과 헤맸던 내용을 정리했다.

API의 null 리턴

이번에 사용한 Harry Potter API는 String 타입을 리턴할 때 없는 정보를 null이 아닌 빈 스트링으로 리턴한다.

처음에는 데이터 Serialization을 할 때 빈 스트링을 멤버 변수로 매핑 했지만, 코드를 작성하다 보니 없는 정보를 출력하지 않거나 하는 등의 코드에서 str != ''로 비교하는 것 보다 str != null로 비교하는 것이 더 직관적이라고 생각되었다.

어떻게 해야할지 고민하다가 해당 멤버 변수를 nullalbe로 설정하고, 데이터를 Serialization 할 때 빈 스트링이 반환되면 null을 넣어주는 방식으로 코드를 변경했다. 아래는 character.dart파일에서 Charater.fromMap() 생성자의 house 변수 매핑 예시이다. 이와 같은 방식으로 빈 스트링을 리턴하는 멤버 변수를 모두 변경했다.

house: map['house'] != '' ? House.fromHouseName(map['house']) : null,

enum 사용 (HouseType)

Harry Potter API는 기숙사의 정보인 house를 스트링으로 리턴한다. 하지만 앱을 만들면서 이러한 기숙사의 정보를 비교 연산에 많이 사용하게 되어 enum의 상수 타입들로 선언하고 싶었다.

또한 해리포터의 각 기숙사는 대표하는 색상과 로고가 있는데 이를 기숙사 정보에 같이 저장하여 사용하면 편리할 것 같았다.

따라서 우선 House클래스를 만들고, 멤버 변수로 기숙사 타입과, 색상, 로고 이미지를 저장했다. 이를 사용해 Character 클래스에서 House를 네트워크에서 받아온 스트링으로 저장하지 않고, 스트링을 넣어 House를 생성하여 저장할 수 있도록 했다.

또한 House 클래스에서 사용할 기숙사의 타입을 enum으로 생성하기 위해 HouseType을 만들었는데 Dart에서 enum을 클래스처럼 사용할 수 있다고 해서 기숙사의 이름을 입력받아 타입을 설정하는 생성자도 만들었다. (enum을 이러한 방식으로 사용해 본 적이 없어서 클래스 형태로 만들고 생성하는 방법을 학습하느라 시간이 많이 소요됐다.)

이렇게 House를 임의로 클래스 모델로 만들고 기숙사의 타입도 상수 타입들로 선언해 사용하니 이후의 코드 작성들은 훨씬 편리했다.

flutter apk 추출 시 인터넷 권한 설정

해당 내용은 어려웠던 내용은 아니지만 간단하게 추가 내용으로 정리하고자 한다.

과제를 모두 진행하고 apk로 앱을 추출한 뒤 설치해 보았다. 하지만 네트워크에서 데이터를 가져오지 못했다. 이유를 찾아보니 안드로이드의 인터넷 권한을 설정해야 네트워크 통신이 가능했다. 아래는 인터넷 권한을 설정하는 방법을 정리했다.

먼저 아래의 디렉토리의 파일을 연다.

android > app > src > main > AndroidManifest.xml

해당 파일에서 아래 코드처럼 인터넷 권한을 추가하면 된다.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.my_app">
  	<!-- 아래의 uses-permission을 추가 -->
    <uses-permission android:name="android.permission.INTERNET"/>
  	<application
                 ...
    </application>
</manifest>

추가로 아래 내용은 flutter에서 apk를 추출하는 방법이다.

flutter apk 빌드
1. 터미널에서 해당 프로젝트 디렉토리로 이동
2. 터미널에 아래 커맨드 입력

flutter build apk --release --target-platform=android-arm64
  1. 아래 디렉토리에 apk 파일 생성됨
[Project root]/build/app/outputs/apk/release/apk-release.apk

주간 과제 완료

처음으로 직접 앱을 기획해서 만들었다. (사실 기획이라 하기도 힘든 간단한 앱이긴 하지만 ㅋㅋㅋㅋ) 처음에 어떤걸 해야될지 생각이 안나서 고민을 오래했다.ㅠㅠ 방식은 기존에 항상 하던 데이터를 받아와서 출력해주는 앱이지만 나름 만족스러운 것 같다.ㅎㅎ 포스팅에서 추가 사항을 쓸 때 어려웠던 점을 이미 정리해서 후기가 따로 쓸 말이 없네... 어쨌든 이번주 한 주 동안 데이터 Serialization을 계속 연습했더니 정말 많이 적응된 것 같다. 😋😋

📄Reference

profile
관우로그

0개의 댓글

관련 채용 정보