[TIL] 기차예매서비스 앱만들기 (3)

청학동버블티·2024년 11월 20일

Flutter 공부

목록 보기
5/18

지난주부터 시작한 기차예매 서비스 앱만들기 과정을 기록해봤다.


<어플리케이션 구성 순서>


1. 전체 UI 틀 짜기
2. ThemeData 파일 작성
3. 페이지 연결
4. 상태관리
5. 알림창 구성

1. 전체 UI 틀 짜기

1-1. 폴더트리

강의 내용에서 배웠던 부분을 응용하여 전체 UI 틀을 짜고 아래와 같이 폴더트리를 나눴다.

- lib
  - pages
    - widgets

lib 폴더에는 main.dart, theme.dart파일을 생성했고
pages 폴더에는 home_page.dart, station_list_page.dart, seat_page.dart 파일,
widgets 폴더엔 나중에 들어갈 위젯들을 넣을 계획이었다.

디자인을 수정하다가 세로선을 그을 일이 있었는데 Container로 설정해야 한다는것도 알게되었다.


1-2. ListView

기차역 리스트를 스크롤하려면 ListView 위젯을 사용하는데 사용법이 익숙치 않아 조금 헤맸다.

  • ListView

ListView는 부모 위젯의 높이에 따라 높이가 결정된다.
이때 Expanded로 감싸는 것이 해당페이지를 차지하는 만큼의 크기가 정해지기 때문에 사용하기 편리하다.

  • ListView.builder

itemCount와 itemBuilder를 사용하여 구성한다.
예를 들면 기차역 리스트를 세팅하고 itemBuilder에서 int idex를 받게 한 다음
Text에서 해당 index에 맞는 기차역을 출력하고 이를 리스트로 정리한다.

  • ListView.separated

ListView.builder에 구분선이 추가된 형태이다.
seperatorBuilder를 추가로 작성, Divider로 반환하면 된다.
원래 마지막 리스트 아래에는 구분선이 없는데 기차역 리스트 마지막에 공백요소를 넣었더니 구분선이 생겼다.


ListView.separated안에 Row - Expanded - Align 순서로 감싸서 리스트를 정렬했다가,
Row를 ListTile로 대치했다. 이렇게 하니 간단하게 기차역 리스트를 만들 수 있었다.
각각의 요소에는 Navigator.pop기능으로 선택한 역정보를 반환하도록 했다.




2. ThemeData 파일 작성

theme 파일에서 설정할 수 있는 디자인이 다양해서 편리하게 수정할 수 있다.
light/dark 모드에 맞게 색상을 수정해주고 버튼 기본디자인도 수정하였다.
이는 ButtonStyle로 모서리 곡률이나 색상, 폰트, 내부 여백 등을 지정할 수 있어 편리하다.


홈페이지 기본 패딩을 설정해두면 버튼생성시 가로길이가 알아서 설정되는점도 편하다.


Card Widget은 둥근 모서리와 그림자 효과가 포함된 박스를 제공하는데
홈페이지에서 출발역/도착역을 선택하는 박스를 위치시키는데 사용하니 편리했다.




3. 페이지 연결

3-1. Navigator.push

UI를 완성하고나서는 페이지끼리 연결을 시도했다.
GestureDetector 기본기능을 활용하여 HomePage에서 선택버튼을 누르면
StationListPage, SeatPage로 이동하게끔 연결했다.


그리고 기차역 선택시 기차역리스트의 AppBar title에 출발역/도착역 출력여부를 변경해야 했다.
이는 Navigator.push기능으로 생성해봤다.

void _navigateToStationList(String title) {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => StationListPage(title: title),
      ),
    ).then((value) {
      setState(() {
        departureStation = value ?? '선택';
        arrivalStation = value ?? '선택'; // 값이 null일 경우 다시 '선택'으로 설정
      });
    });
  }

여기서 Navigator.push가 비동기 작업이어서 then 메서드를 사용한다.
이는 새로운 화면에서 값을 받아와 처리하기 위함이다.
StationListPage에 출발역/도착역 여부를 알려주고
어떤 역을 선택했는지를 value값으로 받아와서 null 일 경우 '선택' 문구를 띄운다.


좌석 선택하는 부분은 아래와 같이 설정했다.

void _navigateToSeatPage() {
    if (_departureStation != '선택' && _arrivalStation != '선택') {
      Navigator.push(
          context, MaterialPageRoute(builder: (context) => const SeatPage()));
    } else {
      // 알림 메시지 표시 (선택되지 않은 버튼이 있다는 메시지)
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('출발역과 도착역을 모두 선택해주세요.')),
      );
    }
  }

기차역을 하나라도 선택하지 않았다면 스낵바에 알림메시지가 뜨도록 했다.


3-2. Navigator.pushNamed

위 코드를 더욱 명확하고 직관적으로 수정하고자 Navigator.pushNamed를 활용했다.

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

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      themeMode: ThemeMode.light,
      theme: lightTheme,
      darkTheme: darkTheme,
      initialRoute: '/',
      routes: {
        '/': (context) => const HomePage(),
        '/stationList': (context) => StationListPage(
              title: ModalRoute.of(context)!.settings.arguments as String)
      },
    );
  }
}


void _navigateToStationList(String title) async {
    final selectedStation =
        await Navigator.pushNamed(context, '/stationList', arguments: title)
            as String?;
    setState(() {
    if (title == '출발역') {
        _departureStation = selectedStation ?? '선택';
      } else {
        _arrivalStation = selectedStation ?? '선택';
      }
    }
  );
}

이렇게 Navigator.pushNamed를 이용하면 특정 화면에 특정값을 전달하고
그 화면에서 선택한 값을 가져와 내가 원하는 위치에 setState로 화면을 렌더링하여 대치하기 편리하다.
이는 MaterialPageRoute를 직접 생성하는 것보다 간결하고 관리가 쉽다고 한다.
MaterialPageRoute는 직접 생성하는 코드를 여러번 반복해야 하는데
named route는 한번 정의하면 여러곳에서 동일한 화면으로 이동할 수 있다.


다만 MaterialApp에서 Route 설정을 해줘야 하는데
initialRoute는 앱 시작시의 첫 표시 화면을 지정해주는 역할을 한다.
route 설정에서 홈페이지를 지정해두면 된다.


이때 StationListPage에서 받는 title은 화면 이동경로 상에서 전달받는 argument이므로

ModalRoute.of(context)!.settings.arguments as String

위와 같이 설정하면 값을 가져올 수 있다.

그런데 SeatPage로 이동시 출발역과 도착역정보를 가져와 출력해야하므로
HomePage에서 좌석 선택부분을 다시 아래처럼 수정했다.

void _navigateToSeatPage() {
    if (_departureStation != '선택' && _arrivalStation != '선택') {
    Navigator.pushNamed(context, '/seatPage', arguments: {
        'departureStation': _departureStation,
        'arrivalStation': _arrivalStation
      });
    }...

그러고나서 SeatPage의 build 위젯 부분에도 매개변수를 추가하고
Scaffold body에서 해당 정보를 출력하도록 했다.

Widget build(BuildContext context) {
    final args = ModalRoute.of(context)!.settings.arguments as Map;
    final departureStation = args['departureStation'];
    final arrivalStation = args['arrivalStation'];...

3-3. 도전과제 1번

도전과제 1번은 기차역 선택시 출발역과 도착역이 중복되는 값이 없도록
하나가 선택되면 다른하나를 선택시 그 값은 제외된 리스트를 제공하는 것이었다.

홈페이지에서 기차역리스트로 이동하는함수를 아래와같이 수정했다.

 void _navigateToStationList(String title) async {
    final String otherSelectedStation =
        title == '출발역' ? _arrivalStation : _departureStation;
    final selectedStation =
     await Navigator.pushNamed(context, '/stationList', arguments: {
      'title': title,
      'selectedStation': otherSelectedStation, // 이전에 선택된 역 정보 전달
    }) as String?;...

그리고나서 기차역리스트페이지에 매개변수(selectedStation)를 추가하고
build함수를 아래와 같이 수정했다.

ListView에서는 filteredStations가 출력되도록 했다.

Widget build(BuildContext context) {
    final args =
        ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
    final String title = args['title'];
    final String otherSelectedStation = args['selectedStation'];
    final filteredStations = stations
        .where((station) =>
            station != selectedStation && station != otherSelectedStation)
        .toList();...

이후에는 main 함수의 route부분을 아래와 같이 수정하여
각 페이지로 전달받은 값을 잘 출력할 수 있도록 했다.

	...routes: {
        '/': (context) => const HomePage(),
        '/home': (context) => const HomePage(),
        '/stationList': (context) {
          final args = ModalRoute.of(context)!.settings.arguments as Map<String, dynamic>;
          return StationListPage(
            title: args['title'],
            selectedStation: args['selectedStation'],
          );
        },
        '/seatPage': (context) => const SeatPage()
      },



4. 상태관리

SeatPage에서 상태관리 클래스를 아래와 같이 생성했다.

class _SeatPageState extends State<SeatPage> {
  int? selectedRow;
  String? selectedCol;

  void onSelected(int rowNum, String colNum) {
    setState(() {
      selectedRow = rowNum;
      selectedCol = colNum;
    });
  }...

SeatPage 내부에 SeatSelectBox widget도 생성했다.
그 내부에도 seat widget을 생성하여 클릭시에 컨테이너색상이 변경되도록 아래와 같이 설정했다.

Widget seat(int rowNum, String colNum) {
    return Padding(
      padding: EdgeInsets.symmetric(horizontal: 2),
      child: GestureDetector(
        onTap: () {
          widget.onSelected(rowNum, colNum);
        },
        child: Container(
          width: 50,
          height: 50,
          decoration: BoxDecoration(
          color: widget.selectedRow == rowNum && widget.selectedCol == colNum
                  ? Colors.purple
                  : Colors.grey[300],
              borderRadius: BorderRadius.circular(8)),
        ),
      ),
    );
  }



5. 알림창

SeatPage에서 좌석 선택시 예매 확인 메시지가 뜨도록 아래와 같이 설정했다.

				ElevatedButton(
                style: const ButtonStyle(),
                onPressed: () {
                  if (widget.selectedRow != null &&
                      widget.selectedCol != null) {
                    showCupertinoDialog(
                      context: context,
                      builder: (context) => CupertinoAlertDialog(
                        title: const Text("예매 하시겠습니까?"),
                      actions: [
                        CupertinoDialogAction(
                          onPressed: () {
                            Navigator.pop(context);
                          },
                          child: const Text(
                            "취소",
                            style: TextStyle(color: Colors.red),
                          ),
                        ),
                        CupertinoDialogAction(
                          onPressed: () {
                            Navigator.pop(context);
                          },
                          child: GestureDetector(
                            onTap: () => Navigator.pop(context),
                            child: const Text(
                              "확인",
                              style: TextStyle(color: Colors.blue),
                            ),
                          ),
                        ),
                      ],
                    ),
                  );
                },
                child: const Text("예매 하기"),
              ),

(여기서 좌석을 선택하지 않을경우 알림메시지를 띄우고싶었는데
잘 되지 않아 중간에 포기했다. 이번주 내로 develop해보고싶다.
else if 를 활용하면 될 것 같다.)

그런데 위 코드에서 확인버튼을 눌러서 홈페이지로 이동했을 때 뒤로가기 버튼이
활성화되어있길래 이를 없애고자 아래와 같이 수정하였다.

							CupertinoDialogAction(
                            onPressed: () {
                            // HomePage로 이동하고 뒤로가기 버튼 완전히 비활성화
                              Navigator.pushAndRemoveUntil(
                                context,
                                MaterialPageRoute(
                                    builder: (context) => HomePage()),
                                (route) => false, // 모든 Route를 제거
                              );
                            },
                            child: GestureDetector(
                              onTap: () => Navigator.pop(context),
                              child: const Text(
                                "확인",
                                style: TextStyle(color: Colors.blue),
                              ),
                            ),
                          ),

이로써 HomePage로 이동함과 동시에 모든 Route를 제거하고
뒤로가기 버튼도 완전히 비활성화하도록 수정됐다.


여기에 선택한 좌석의 정보를 알림창에서 한번 더 확인할 수 있게 아래 코드를
title Text 다음에 삽입했다.

				content: Text(
                '좌석 : ${widget.selectedRow}-${widget.selectedCol}')

그런데 이후에 알림창의 확인버튼을 눌렀을때 계속해서 지연이 발생하길래
아래와 같이 다시한번 수정했다.

							CupertinoDialogAction(
                            onPressed: () {
                              Navigator.pop(context); // 먼저 Dialog를 닫음
                              Navigator.pushAndRemoveUntil(
                                context,
                                MaterialPageRoute(
                                    builder: (context) => const HomePage()),
                                (route) => false,
                              );
                            },
                            child: const Text(
                              "확인",
                              style: TextStyle(color: Colors.blue),
                            ),
                          )

Navigator.pop(context) 을 통해 Dialog를 완전히 닫고
child로 Text만 남겨두었다.
GestureDetector가 있었을 때 충돌해서 문제가 발생했나 싶기도 하다.




길고도 짧았던 일주일간의 과제가 드디어 마무리됐다.
처음으로 도전과제까지 모두 완료했더니 굉장히 뿌듯했다.
주말에 시간좀 내서 미리 더 해둘걸 하는 후회가 들었다.
다음과제는 미리미리 해야하는 작업을 해두는 습관을 들여야겠다.

0개의 댓글