본문 바로가기
프로그래밍/Flutter & Dart

[Flutter] Docker, php, mariaDB를 이용한 서버구성 및 CRUD 구현 - (2)

by 어느덧중반 2021. 6. 11.
반응형

이전 시간까지 docker, nginx 등에 대해 간단히 알아보았고 php, mariaDB를 이용해 백엔드를 구성하고 Flutter와 연동해보자.

Flutter에서 백엔드 DB의 데이터를 불러와 보여주고 기본적인 CRUD 동작을 실습해보자.
이번 시간에는 실제 Flutter 소스를 구현하고 데이터를 DB와 연동하는 부분을 구현해보도록 하겠다.

#1. Flutter 화면 구성

#2. php API 파일 구성

#3. 동작 테스트


#1. Flutter 화면 구성

Flutter 프로젝트를 하나 생성해주자. 기존 docker 폴더와 같은 위치에 생성해보겠다.

App 선택 후 Next

나는 현재 docker 폴더를 flutter_api_example 하위에 위치하도록 만들었었다.
flutter 프로젝트도 생성시 flutter_api_example 폴더에 생성하도록 설정했다.

flutter 프로젝트가 생성되었고 하위에 docker 폴더도 함께 있는 것을 볼 수 있다.

우선 pubspec.yaml 파일부터 셋팅해보자. 데이터를 연동하기 위해 http 패키지만 셋팅해주었다.

이제 화면을 만들어보자.
Home : 앱 실행 후 루트 화면 (student 정보가 있다면 list로 보여주는 화면)
Create : student 객체생성 후 DB에 row 생성하는 화면
Details : student list에서 클릭시 상세 정보 보여주는 화면
Edit : student 정보 수정 화면

우선 main.dart에서 총 4개의 화면으로 이동할 수 있도록 화면을 구성하자.

import 'package:flutter/material.dart';
import 'package:flutter_api_example/screens/create.dart';
import 'package:flutter_api_example/screens/details.dart';
import 'package:flutter_api_example/screens/edit.dart';
import 'package:flutter_api_example/screens/home.dart';

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

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter + PHP CRUD',
      initialRoute: '/',
      routes: {
        '/': (context) => Home(),
        '/create': (context) => Create(),
        '/details': (context) => Details(),
        '/edit': (context) => Edit(),
      },
    );
  }
}


이제 4개의 화면을 각각 구성해보자.
Home 화면 (/lib/screens 폴더를 생성하고 그 하위에 넣어주었다. home.dart)

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'dart:convert';

import 'package:http/http.dart' as http;
import '../env.dart';
import '../models/student.dart';
import './details.dart';
import './create.dart';

class Home extends StatefulWidget {
  Home({Key key}) : super(key: key);
  @override
  HomeState createState() => HomeState();
}

class HomeState extends State<Home> {
  Future<List<Student>> students;
  final studentListKey = GlobalKey<HomeState>();
  Dio dio = Dio();

  @override
  void initState() {
    super.initState();
    students = getStudentList();
  }

  Future<List<Student>> getStudentList() async {
    var responseWithDio;
    List<Student> students;

    // Dio 이용하여 통신
    try {
      // 서버로 요청
      responseWithDio = await dio.get("${Env.URL_PREFIX}/list.php");

      students = (responseWithDio.data).map<Student>((json) {
        return Student.fromJson(json);
      }).toList();
    } catch (e) {
      print(e);
    }

    return students;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      key: studentListKey,
      appBar: AppBar(
        title: Text('Dio, mysqli 이용한 CRUD'),
      ),
      body: Center(
        child: FutureBuilder<List<Student>>(
          future: students,
          builder: (BuildContext context, AsyncSnapshot snapshot) {
            // By default, show a loading spinner.
            if (!snapshot.hasData) return CircularProgressIndicator();
            // Render student lists
            return ListView.builder(
              itemCount: snapshot.data.length,
              itemBuilder: (BuildContext context, int index) {
                var data = snapshot.data[index];
                return Card(
                  child: ListTile(
                    leading: Icon(Icons.person),
                    trailing: Icon(Icons.view_list),
                    title: Text(
                      data.name,
                      style: TextStyle(fontSize: 20),
                    ),
                    onTap: () {
                      Navigator.push(
                        context,
                        MaterialPageRoute(
                            builder: (context) => Details(student: data)),
                      );
                    },
                  ),
                );
              },
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add),
        onPressed: () {
          Navigator.push(context, MaterialPageRoute(builder: (_) {
            return Create();
          }));
        },
      ),
    );
  }
}


Create 화면 (/lib/screens 폴더를 생성하고 그 하위에 넣어주었다. create.dart)

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../env.dart';
import '../widgets/form.dart';

class Create extends StatefulWidget {
  final Function refreshStudentList;

  Create({this.refreshStudentList});

  @override
  _CreateState createState() => _CreateState();
}

class _CreateState extends State<Create> {
  final formKey = GlobalKey<FormState>();
  Dio dio = Dio();
  // Handles text onchange
  TextEditingController nameController = new TextEditingController();
  TextEditingController ageController = new TextEditingController();

  // Http post request to create new data
  Future _createStudent() async {
    var formData = FormData.fromMap(
      {
        "name": nameController.text,
        "age": ageController.text,
        "createdAt": DateTime.now().toString()
      },
    );

    return await dio.post("${Env.URL_PREFIX}/create.php", data: formData);
  }

  void _onConfirm(context) async {
    await _createStudent();

    // Remove all existing routes until the Home.dart, then rebuild Home.
    Navigator.of(context)
        .pushNamedAndRemoveUntil('/', (Route<dynamic> route) => false);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Create"),
      ),
      bottomNavigationBar: BottomAppBar(
        child: RaisedButton(
          child: Text("CONFIRM"),
          color: Colors.blue,
          textColor: Colors.white,
          onPressed: () {
            if (formKey.currentState.validate()) {
              _onConfirm(context);
            }
          },
        ),
      ),
      body: Container(
        height: double.infinity,
        padding: EdgeInsets.all(20),
        child: Center(
          child: Padding(
            padding: EdgeInsets.all(12),
            child: AppForm(
              formKey: formKey,
              nameController: nameController,
              ageController: ageController,
            ),
          ),
        ),
      ),
    );
  }
}


Details 화면 (/lib/screens 폴더를 생성하고 그 하위에 넣어주었다. details.dart)

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';

import 'package:http/http.dart' as http;
import '../env.dart';
import '../models/student.dart';
import './edit.dart';

class Details extends StatefulWidget {
  final Student student;

  Details({this.student});

  @override
  _DetailsState createState() => _DetailsState();
}

class _DetailsState extends State<Details> {
  Dio dio = Dio();

  void deleteStudent(context) async {
    var formData = FormData.fromMap({'id': widget.student.id.toString()});

    await dio.post("${Env.URL_PREFIX}/delete.php", data: formData);
    // Navigator.pop(context);
    Navigator.of(context)
        .pushNamedAndRemoveUntil('/', (Route<dynamic> route) => false);
  }

  void confirmDelete(context) {
    showDialog(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          content: Text('Are you sure you want to delete this?'),
          actions: <Widget>[
            RaisedButton(
              child: Icon(Icons.cancel),
              color: Colors.red,
              textColor: Colors.white,
              onPressed: () => Navigator.of(context).pop(),
            ),
            RaisedButton(
              child: Icon(Icons.check_circle),
              color: Colors.blue,
              textColor: Colors.white,
              onPressed: () => deleteStudent(context),
            ),
          ],
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Details'),
        actions: <Widget>[
          IconButton(
            icon: Icon(Icons.delete),
            onPressed: () => confirmDelete(context),
          ),
        ],
      ),
      body: Container(
        height: 270.0,
        padding: const EdgeInsets.all(35),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(
              "Name : ${widget.student.name}",
              style: TextStyle(fontSize: 20),
            ),
            Padding(
              padding: EdgeInsets.all(10),
            ),
            Text(
              "Age : ${widget.student.age}",
              style: TextStyle(fontSize: 20),
            ),
            Padding(
              padding: EdgeInsets.all(10),
            ),
            Text(
              "Created : ${widget.student.createdAt.toString().substring(0, 10)}",
              style: TextStyle(fontSize: 20),
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.edit),
        onPressed: () => Navigator.of(context).push(
          MaterialPageRoute(
            builder: (BuildContext context) => Edit(student: widget.student),
          ),
        ),
      ),
    );
  }
}


Edit 화면 (/lib/screens 폴더를 생성하고 그 하위에 넣어주었다. edit.dart)

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';

// import 'package:http/http.dart' as http;
// import 'package:http/http.dart';
import '../env.dart';
import '../models/student.dart';
import '../widgets/form.dart';

class Edit extends StatefulWidget {
  final Student student;

  Edit({this.student});

  @override
  _EditState createState() => _EditState();
}

class _EditState extends State<Edit> {
  // This is  for form validations
  final formKey = GlobalKey<FormState>();

  // This is for text onChange
  TextEditingController nameController;
  TextEditingController ageController;

  Response response;

  Dio dio = Dio();

  // Dio post request
  Future editStudent() async {
    var formData = FormData.fromMap(
      {
        "id": widget.student.id.toString(),
        "name": nameController.text,
        "age": ageController.text
      },
    );

    return await dio.post("${Env.URL_PREFIX}/update.php", data: formData);
  }

  void _onConfirm(context) async {
    await editStudent();
  }

  @override
  void initState() {
    nameController = TextEditingController(text: widget.student.name);
    ageController = TextEditingController(text: widget.student.age.toString());
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Edit"),
      ),
      bottomNavigationBar: BottomAppBar(
        child: RaisedButton(
          child: Text('CONFIRM'),
          color: Colors.blue,
          textColor: Colors.white,
          onPressed: () {
            _onConfirm(context);
            Navigator.of(context)
                .pushNamedAndRemoveUntil('/', (Route<dynamic> route) => false);
          },
        ),
      ),
      body: Container(
        height: double.infinity,
        padding: EdgeInsets.all(20),
        child: Center(
          child: Padding(
            padding: EdgeInsets.all(12),
            child: AppForm(
              formKey: formKey,
              nameController: nameController,
              ageController: ageController,
            ),
          ),
        ),
      ),
    );
  }
}

 

Student 클래스 (lib / models 폴더를 생성하고 그 하위에 넣어주었다. student.dart)

class Student {
  final int id;
  final String name;
  final int age;
  final String createdAt;

  Student({this.id, this.name, this.age, this.createdAt});

  factory Student.fromJson(Map<String, dynamic> json) {
    return Student(
        id: int.parse(json['id']),
        name: json['name'],
        age: int.parse(json['age']),
        createdAt: json['createdAt']);
  }

  Map<String, dynamic> toJson() =>
      {'name': name, 'age': age, 'createdAt': createdAt};
}

 

AppForm 위젯 (/lib/widgets 폴더를 생성하고 그 하위에 넣어주었다. form.dart)

import 'package:flutter/material.dart';

class AppForm extends StatefulWidget {
  // Required for form validations
  GlobalKey<FormState> formKey = GlobalKey<FormState>();

  // Handles text onchange
  TextEditingController nameController;
  TextEditingController ageController;

  AppForm({this.formKey, this.nameController, this.ageController});

  @override
  _AppFormState createState() => _AppFormState();
}

class _AppFormState extends State<AppForm> {
  String _validateName(String value) {
    if (value.length < 3) return 'Name must be more than 2 charater';
    return null;
  }

  String _validateAge(String value) {
    Pattern pattern = r'(?<=\s|^)\d+(?=\s|$)';
    RegExp regex = new RegExp(pattern);
    if (!regex.hasMatch(value)) return 'Age must be a number';
    return null;
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: widget.formKey,
      autovalidate: true,
      child: Column(
        children: <Widget>[
          TextFormField(
            controller: widget.nameController,
            keyboardType: TextInputType.text,
            decoration: InputDecoration(labelText: 'Name'),
            validator: _validateName,
          ),
          TextFormField(
            controller: widget.ageController,
            keyboardType: TextInputType.number,
            decoration: InputDecoration(labelText: 'Age'),
            validator: _validateAge,
          ),
        ],
      ),
    );;
  }
}

 

Env 클래스 (/lib/ 하위에 파일을 생성해주었다. env.dart)

class Env { static String URL_PREFIX = "http://127.0.0.1/flutter_api"; }


화면이 정상적으로 뜨는지 한번 실행해보자.

우선 빌드는 잘 되고 실행되었다.
실행 후 루트화면인 Home 화면

 

#2. php API 파일 구성

- php API 에서는 실제 DB에서 CRUD역할을 할 파일들을 생성하고 구현해볼 것이다.
- 먼저 docker/home/ 폴더 아래에 flutter_api 폴더를 하나 만들고 그 안에 db.php 파일을 만들어주자.

<?php

$db_name = "mydb";
$db_server = "mariadb";
$db_user = "myuser";
$db_pass = "mypath12345";

// PDO 이용한 구현
// $db = new PDO("mysql:host={$db_server};dbname={$db_name};charset=utf8", $db_user, $db_pass);
// $db->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
// $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

// mysqli 이용한 구현
$db = mysqli_connect($db_server, $db_user, $db_pass, $db_name);

if (!$db) {
	die("Connection failed: " . mysqli_connect_error());
}
?>

db.php 파일에서는 db의 연결을 담당한다.

- 다음으로 같은 폴더 아래에 list.php 파일을 만들어주자.
list.php 에서는 db.php를 include 하고 있기 때문에 db 연결은 된 상태일거고,
$stmt 변수에 쿼리문을 담아 실행하고 $result 변수에 결과값을 담아주고 json 데이터로 인코드 하며 return 해주는 역할을 한다.

<?php
header('Content-Type: application/json');
include "../flutter_api/db.php";

// PDO 이용한 구현
// $stmt = $db->prepare("SELECT id, name, age, createdAt FROM student");
// $stmt->execute();
// $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
// echo json_encode($result);

// mysqli 이용한 구현
$stmt = "SELECT id, name, age, createdAt FROM student";

# 쿼리 실행
$result = mysqli_query($db, $stmt);
$result_array = array();
# 루프 돌며 배열에 오브젝트 하나씩 담기
# mysqli_fetch_array 사용 옵션 : MYSQLI_NUM : 키를 숫자로 사용 $row[0], MYSQLI_ASSOC : 키를 데이터키로 사용 $row["col1"], MYSQLI_BOTH : 둘다 사용
while($row = mysqli_fetch_array($result, MYSQLI_ASSOC)) {
    $result_array[] = $row;
}
# 결과값 json 형식으로 변환
echo json_encode($result_array);

mysqli_close($db);


- 다음으로 같은 폴더 아래에 details.php 파일을 만들어주자.
details 화면에서는 해당 student를 클릭했을 때 그 student의 id를 이용해 name, age 정보를 가져오고 json 데이터로 인코드 후에 return 해주는 역할을 한다.

<?php
header('Content-Type: application/json');
include "../flutter_api/db.php";

$id = (int) $_POST['id'];

$stmt = $db->prepare("SELECT name, age, createdAt FROM student WHERE ID = ?");
$stmt->execute([$id]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);

echo json_encode([
'result' => $result
]);


- 다음으로 같은 폴더 아래에 create.php 파일을 만들어주자.
create 화면에서는 입력받아 넘어온 name과 age 값을 db에 insert하고 성공시 success 를 json 데이터로 인코드 후에 return 하는 역할을 한다.

<?php
header('Content-Type: application/json');
include "../flutter_api/db.php";

$name = $_POST['name'];
$age = (int) $_POST['age'];
$createdAt = $_POST['createdAt'];

$stmt = $db->prepare("INSERT INTO student (name, age, createdAt) VALUES (?, ?, ?)");
// $result = $stmt->execute([$name, $age, $createdAt]);
$stmt->bind_param("sis", $name, $age, $createdAt);
$result = $stmt->execute();

echo json_encode([
'success' => $result
]);


- 다음으로 같은 폴더 아래에 delete.php 파일을 만들어주자.
delete 화면에서는 지우고자 하는 student의 id값으로 db에서 값을 입력받고 삭제한 id와 쿼리문 실행결과를 json 데이터로 인코드 후에 return 하는 역할을 한다.

<?php
header('Content-Type: application/json');
include "../flutter_api/db.php";

$id = (int) $_POST['id'];
$stmt = $db->prepare("DELETE FROM student WHERE id = ?");
$stmt->bind_param("i", $id);
$result = $stmt->execute();
// $result = $stmt->execute([$id]);

echo json_encode([
'id' => $id,
'success' => $result
]);

?>

 

#3. 동작 테스트

- 최초 앱 실행 : Home화면 => list.php 학생 목록 가져오기 (나는 db에 일부 값들을 미리 넣어놓았다.)

- Create (우측 하단 '+' 버튼)

'+'버튼 클릭 &gt; name,age입력 후 CONFIRM &gt; Home화면에 추가된 데이터 확인

- Details (리스트의 Student 클릭) / Edit (Details 화면에서 우측하단 edit버튼 클릭)

리스트의 Student클릭 &gt; 우측하단 edit버튼 클릭 &gt; age정보 수정 후 CONFIRM &gt; 다시 리스트의 수정한 Student 클릭 후 변경된 age확인


- Details > Delete (Student 삭제)

Details 화면 우측상단 delete버튼 클릭 &gt; 팝업창 확인버튼 클릭 &gt; Home 화면에 Student 삭제됨 확인

 


내 블로그는 코드의 복사/붙여넣기를 방지하고자 복사기능을 넣지 않았다. 가급적 직접 타이핑 하는 것을 추천하며 타이핑 오류로 고생을 한다면 아래 repository에서 소스를 확인하기 바란다.

 

kyungsnim/flutter_api_example

Contribute to kyungsnim/flutter_api_example development by creating an account on GitHub.

github.com

 

지난 편과 함께 docker를 이용해 nginx, php, mariadb와 연동하여 작동하는 flutter 화면개발을 해보았다. 다음 시간에는 docker를 로컬이 아닌 클라우드 서버에 올리는 방법에 대해 알아보겠다.

반응형

댓글