QR코드를 이용한 스마트 출석 앱을 만들다가 (과제)

 

로그인 기능과 회원가입 기능에 대한 것을 포함하게 되어 끄적여봅니다.

우선 로그인 화면과 회원가입 화면입니다.

 

로그인 화면에서 username과 password는 데이터베이스에 저장되어 있으며 입력 문자가 너무 짧은 경우 토스트 메시지로 사용자에게 알려줍니다.

 

회원가입 화면에서도, username과 password가 너무 짧다면 거절당하고 password와 confirm이 다를 경우에도 가입이 되지 않습니다.

 

코드

MainActivity.java

 

package com.example.smartattendance;

import android.content.Context;
import android.content.Intent;
import android.database.sqlite.SQLiteDatabase;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.support.annotation.Nullable;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;
import android.widget.ToggleButton;

public class MainActivity extends AppCompatActivity {

Button button;

SQLiteDatabase newDB;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

if (newDB == null) {
String dbName = "new";
openDatabase(dbName);
} else {
Toast.makeText(this, "test", Toast.LENGTH_LONG).show();
}

button = (Button) findViewById(R.id.main_button);
button.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent intent = new Intent(getApplicationContext(), LoginActivity.class);
startActivityForResult(intent, 101);
}
});
}

@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);

if (requestCode == 101 && resultCode == RESULT_OK) {
String userId = data.getStringExtra("userId");

Intent intent = new Intent(getApplicationContext(), SubjectActivity.class);
intent.putExtra("userId", userId);
startActivityForResult(intent, 102);
} else {
button.setText("로그인에 실패했습니다.\n 다시 로그인 해주세요");
}
}

public void openDatabase(String dbName) {
DatabaseHelper helper = new DatabaseHelper(this, dbName, null, 1);
newDB = helper.getWritableDatabase();
}
}

 

// onCreate()에서 데이터베이스가 없다면 새로 만들게 됩니다.

 

LoginActivity.java

 

package com.example.smartattendance;

import android.app.Activity;
import android.content.Intent;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;

public class LoginActivity extends AppCompatActivity {

Button loginButton;
Button memberButton;

EditText id;
EditText password;

String userId;

boolean userExist;

SQLiteDatabase newDB;
DatabaseHelper helper;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_login);

helper = new DatabaseHelper(this, "new", null, 1);


loginButton = (Button) findViewById(R.id.login_button);
memberButton = (Button) findViewById(R.id.member_button);

id = (EditText) findViewById(R.id.id_input);
password = (EditText) findViewById(R.id.password_input);

loginButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
userExist = false;

String isId = id.getText().toString().trim();
String isPass = password.getText().toString().trim();
if (isId.length() > 4 && isPass.length() > 4)
searchData(isId, isPass);
else
Toast.makeText(LoginActivity.this, "입력이 잘못되었습니다.", Toast.LENGTH_SHORT);

if (userExist) {
Intent intent = new Intent();
intent.putExtra("userId", userId);
setResult(Activity.RESULT_OK, intent);
finish();
} else {
Toast.makeText(LoginActivity.this, "아이디 혹은 비밀번호가" +
"없거나 잘못되었습니다.", Toast.LENGTH_SHORT).show();
}
}
});

memberButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent intent = new Intent(getApplicationContext(), MemberActivity.class);
startActivityForResult(intent, 203);
}
});
}

public void searchData(String isId, String isPass) {
newDB = helper.getReadableDatabase();
String sql = ("select userId, password from test");
Cursor cursor = newDB.rawQuery(sql, null);

for (int i = 0; i < cursor.getCount(); i++) {
cursor.moveToNext();
String id = cursor.getString(0);
String password = cursor.getString(1);
if (id.equals(isId) && password.equals(isPass)) {
userId = id;
userExist = true;
break;
}
}
}
}

 

// 데이터베이스는 DatabaseHelper에서 관리합니다.

사용자의 입력을 데이터베이스에 있는 정보와 비교하고 로그인 성공과 실패를 결정합니다.

 

MemberActivity.java

 

package com.example.smartattendance;

import android.app.Activity;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;

import java.sql.Time;

public class MemberActivity extends AppCompatActivity {

EditText userId;
EditText userPassword;

SQLiteDatabase newDB;
DatabaseHelper helper;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_member);

helper = new DatabaseHelper(this, "new", null, 1);

userId = (EditText) findViewById(R.id.member_id);
userPassword = (EditText) findViewById(R.id.member_password);

Button joinButton = (Button) findViewById(R.id.join_button);
joinButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
String id = userId.getText().toString().trim();
String password = userPassword.getText().toString().trim();

if (id.length() < 5 || password.length() < 5) {
Toast.makeText(MemberActivity.this, "아이디 다섯 글자 이상 \n" +
"비밀번호 다섯 글자 이상" +
"\n 입력해주세요.", Toast.LENGTH_LONG).show();
} else {
insertData(id, password);
setResult(Activity.RESULT_OK);
finish();
}
}
});

Button cancelButton = (Button) findViewById(R.id.cancel_button);
cancelButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
setResult(Activity.RESULT_CANCELED);
finish();
}
});
}

public void insertData(String id, String password) {
newDB = helper.getWritableDatabase();

String sql = ("insert into test(userId, password) values " +
"(" + "'" + id + "'" + "," + "'" + password + "'" + ")");

newDB.execSQL(sql);
}
}

 

// 조건을 만족한다면 아이디와 패스워드를 데이터베이스에 기록합니다. 각 테이블에서 두 데이터는 같은 id를 가집니다.

 

DatabaseHelper.java

 

package com.example.smartattendance;

import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;

public class DatabaseHelper extends SQLiteOpenHelper {
public DatabaseHelper(Context context, String name, SQLiteDatabase.CursorFactory factory, int version) {
super(context, name, factory, version);
}

@Override
public void onCreate(SQLiteDatabase db) {

String userTable = "test";
db.execSQL("create table if not exists " + userTable + " ("
+ " _id integer PRIMARY KEY autoincrement, "
+ " userId text, "
+ " password text);");

String userSubjectTable = "test2";
db.execSQL("create table if not exists " + userSubjectTable + " ("
+ " _id integer PRIMARY KEY autoincrement, "
+ " userId text, "
+ " sub text, "
+ " day text, "
+ " start_time text, "
+ " finish_time text, "
+ " FOREIGN KEY (userId) REFERENCES test (userId));");

}

@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
onCreate(db);
}
}

 

마지막으로 DatabaseHelper 클래스입니다. 데이터베이스 생성. 데이터 기록밖에 하지 않기 때문에 간단하고 짧습니다.

 

부스트코스 안드로이드 프로그래밍에서 만들었던 DatabaseHelper에 비하면 십분의 일 수준이네요.

 

기능으로 묵는다면 LoginActivity.java의 searchData() 메소드를 DatabaseHelper로 옮기는 것도 괜찮겠네요.

일부 코드만 살펴보도록 하겠습니다.

 

서버 통신에 대한 자세한 정보는 구글링하면 많이 나오고, 저 같은 경우 파이썬과 연동하기 위해서 온갖 자료를 찾다가, 겨우 찾은 동영상에서 엄청난 도움을 받았습니다.

 

https://www.youtube.com/watch?v=b7VkbAUqMqMhttps://youtu.be/b7VkbAUqMqM

 

해당 링크입니다. 저는 Face Detect를 Measuring size of object로 바꿔 응용했을 뿐입니다.

 

1. 파일 선택, 저장을 위한 메소드

서버로 파일, 그러니까 사진을 전송하기 전 저장소에 접근하고 가져와야합니다.

 

private void onCamera() {
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);

if (intent.resolveActivity(getPackageManager()) != null) {
File photoFile = null;
try {
photoFile = saveImageFile();
} catch (IOException e) {
// Error occurred while creating the File
e.printStackTrace();
println("저장 실패");
}
// 파일 생성에 성공했을 경우에만 동작
if (photoFile != null) {
Uri photoURI = FileProvider.getUriForFile(this,
"com.example.upload.fileprovider",
photoFile);
intent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI);
startActivityForResult(intent, CAMERA_REQUEST);
}
}
}

 

//이미지 파일을 내부 저장소에 저장하는 메소드
private File saveImageFile() throws IOException {
File storageDir = new File("/sdcard/DCIM/Camera");
println("sdir : " + storageDir.toString());

current = System.currentTimeMillis();
date = new Date(current);
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_hhmmss");
String name = sdf.format(date);

File image = File.createTempFile(
name, /* prefix */
".jpg", /* suffix */
storageDir /* directory */
);

currentImagePath = image.getAbsolutePath();

return image;
}

 

onCamera는 앱에서 카메라를 사용하는 메소드입니다. 실패했을 경우 log를 남깁니다.

try구문에서 saveImageFile() 메소드로 이동하게 되는데 이것은 이미지 파일을 저장하는 메소드입니다.

 

/sdcard/DCIM/Camera 경로에 name(현재시간)으로 파일을 저장합니다.

 

2. 사진을 서버에 업로드

private void uploadImage() {
if (selectedFileUri != null) {
dialog = new ProgressDialog(MeasureActivity.this);
dialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
dialog.setMessage("Uploading....");
dialog.setIndeterminate(false);
dialog.setMax(100);
dialog.setCancelable(false);
dialog.show();

File file = null;
try {
file = new File(Common.getFilePath(this, selectedFileUri));
} catch (URISyntaxException e) {
e.printStackTrace();
}

if (file != null) {
final ProgressRequestBody requestBody = new ProgressRequestBody(file, this);

final MultipartBody.Part body = MultipartBody.Part.createFormData("image", file.getName(), requestBody);

new Thread(new Runnable() {
@Override
public void run() {
mService.uploadFile(body)
.enqueue(new Callback<String>() {
@Override
public void onResponse(Call<String> call, Response<String> response) {
dialog.dismiss();

String image_processed_link;

image_processed_link = new StringBuffer("http://10.11.6.53:8000/" +
response.body().replace("\\", "")).toString();


String[] str = image_processed_link.split("\"");
String imageLink = "";
for (int i = 0; i < str.length; i++)
imageLink += str[i];

Picasso.get().load(imageLink).into(imageView);
basicsImage.setImageBitmap(null);

}

@Override
public void onFailure(Call<String> call, Throwable t) {
dialog.dismiss();
t.printStackTrace();
Toast.makeText(MeasureActivity.this, "" + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
}
}).start();
}
} else {
Toast.makeText(this, "Cannot upload this file!!", Toast.LENGTH_SHORT).show();
}
}

 

업로드 버튼을 눌렀을 때 동작하는 메소드입니다.

ImageView에 이미지가 있을 경우 업로드를 시도하며, dialog로 확인할 수 있습니다. 하지만 제대로 동작한다면 dialog는 보이지 않을 정도로 순식간에 지나가고, 문제가 있을 경우 dialog 화면이 지속되다가 오류 메시지를 보게 됩니다.

 

중요한 부분은

 

if (file != null) {
final ProgressRequestBody requestBody = new ProgressRequestBody(file, this);

 

부터입니다. 스레드가 실행되면서 mService.uploadFile(업로드API)가 body를 변수로 받아 서버로 보내면

응답(response)을 받게 되는데 그것을 Picasso를 이용하여 imageView에 set합니다.

 

여기서 주의할 점은 영상을 따라가다 보면 RetrofitClient 클래스를 만들게 되는데

해당 클래스의 Retrofit의 baseUrI와 uploadImage()의 image_processed_link의 Uri가 같아야 한다는 겁니다.

 

둘이 다르면 절대로 동작하지 않습니다. 추가로 설명하면 영상에서는 10.0.2.2를 사용하는데, 실제 디바이스의 경우 PC의 IP를 사용해야합니다.

 

또 하나 주의할 점은 이 코드는 http를 기준으로 동작한다는 겁니다. 서버가 https일 경우 오류가 발생할 겁니다.

 

 

다른 코드의 경우 링크 영상에 전부 있는 내용이라 생략하겠습니다.

개요

사물의 사이즈를 측정하는 앱 만들기

내용

규격화 된 물체(A4용지, 동전 등)의 사이즈를 이용하여 다른 물건의 사이즈 측정.

 

본래 구글 AR줄자 같은 앱을 만들려고 했지만 제 수준으로는 불가능한 일이었습니다...

결국 차선책으로 opencv를 사용하여 크기를 측정하는 python 코드를 안드로이드 앱과 연동하는 것을 선택했습니다.

(이는 팀 프로젝트이지만 모든 코드와 디자인을 제가 짰다고 보시면 됩니다.)

 

간략한 구조는 이렇습니다.

 

1. 파이썬 flask를 이용하여 만든 간단한 서버를 오픈한다.

2. 모바일 앱에서 조건에 맞는 사진을 서버로 전송한다.

3. opencv를 이용하여 사진에 있는 물건의 사이즈를 측정한다.

4. 측정된 사진을 서버에서 모바일 앱으로 전송한다.

 

opencv를 사용하여 물체의 크기를 측정 - 이곳의 오픈 소스를 사용했습니다.

https://www.pyimagesearch.com/2016/03/28/measuring-size-of-objects-in-an-image-with-opencv/

 

Measuring size of objects in an image with OpenCV - PyImageSearch

Today, I'll demonstrate how you can compute the size of objects in an image using OpenCV, Python, and computer vision + image processing techniques.

www.pyimagesearch.com

 

오픈소스를 19번 라인(ap = argparse.ArgumentParser())에서 118번 라인(cv2.waitKey(0))까지 함수로 만든 후

return하도록 변경했고, 추가로 flask를 사용하여 간단한 서버를 구현했습니다.

 

앱에서는 http통신을 위하여 RetrofitClient를 사용했으며 이것으로 서버에 접속했습니다.

 

자세한 코드는 다음 글에 소개하겠습니다.

 

성능

성능 자체는 좋았습니다. 사진의 각도에 따라 조금씩 다르지만 큰 오차를 보이지 않았습니다. 오차는 실제 크기에서 대략 ±3cm 수준입니다. 하지만 단점이 너무 명확하기 때문에 실제로 사용하기엔 힘들 것으로 보입니다.

 

단점

1. 먼저 규격화된 물건이 필요하다.

아래 before - after를 보면 창문 좌측에 검은색 A4용지가 붙어있음을 확인할 수 있습니다. 네, 바로 그것이 규격화된 물건입니다. 앞에 명시한 링크에서도 확인할 수 있듯 기본적으로 좌측 상단에 기준 물체가 있어야만 합니다. 또한 기준 물체의 사이즈를 알고 있어야 합니다.

 

이는 매우 치명적인 단점으로 앱이 가질 수 있는 편리함을 없앤다고 볼 수 있습니다.

 

2. flask를 사용한다는 것

flask를 사용했기 때문에 서버의 ip주소와 모바일에서 사용하는 네트워크의 ip주소가 일치해야만 합니다. 같은 네트워크(와이파이 등)에 접속한 상태가 아닌, 서로 다른 네트워크를 사용하고 있다면 모바일 앱은 서버에 접속할 수 없습니다.

 

이는 톰캣 등으로 서버를 새로 구현한다면 해결이 가능한 문제로 보입니다.

 

3. 이미지 인식 불안정

측정하려는 사진(이미지)가 어둡거나 선명하지 않다면 제대로 측정되지 않습니다. 실제 스마트폰에서 즉석으로 사진을 찍어 실험한 결과 사진이 조금 어둡거나 좌측 상단의 배경이 조금 다르다면 기준 물체를 잘못 인식하는 경우가 발생합니다.

어느 정도 완성된 모습

 

Splash
Allow
Main
Measure
before
after

 

수식을 계산할 때 곱셈과 덧셈 중 곱셈을 먼저하는 등, 우선순위가 있는 기호들이 있다는 건 기본이죠.그런데 그것을 알고리즘으로 구현하려고 하니까, 쉽게 되지 않고 코드만 복잡해지고 결과는 실패하는 일이 많았습니다.

 

그때 떠오른 것이 바로 자료구조!!

 

자료구조 수업을 들었던 게 몇 년 전인데 신기하게 떠오르더군요. 자료구조에서 후위표기식이라는 것이 있는데, 그것을 적용하여 입력을 계산하니 옳바른 결과가 나왔습니다.

 

후위표기식에 대해서 더 자세히 알아보시려면 아래를 참고하세요.

https://jamanbbo.tistory.com/54

 

[Stack]사칙연산 계산기 구현(2) - 후위 표기 수식 계산

저번 포스팅에서는 사칙연산 계산기 프로그램을 만들기 위한 중위 표기식을 후위 표기법을 이용해 수식을 표현하는 방법을 알아보았다. [Stack]사칙연산 계산기 구현(1) - 후위 표기법 이제 후위 표기 수식을 계산..

jamanbbo.tistory.com

 

코드

CalculateHelper.java

 

package com.example.calculator;

import android.util.Log;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Stack;

//https://jamanbbo.tistory.com/54의 소스코드를 참조, 수정하였음
public class CalculateHelper {
public static double num1;
public static double num2;
public static double resultNumber;

//사용자의 input을 각각 구분하여 ArrayList에 저장하는 메소드
private ArrayList splitTokens(String equation) {
String[] constant = equation.split(" "); //공백을 기준

ArrayList constantList = new ArrayList();
double number = 0;

boolean flag = false;
for (String data : constant) {
if (data.equals(" ")) {
continue;
}
if (checkNumber(data)) {
number = number * 10 + Double.parseDouble(data);
flag = true;
} else {
if (flag) {
constantList.add(number);
number = 0;
}
flag = false;
constantList.add(data);
}
}

if (flag) {
constantList.add(number);
}

return constantList;
}

//후위 표기식으로 변형
private ArrayList infixToPostfix(ArrayList constant) {
ArrayList result = new ArrayList();
HashMap level = new HashMap();
Stack stack = new Stack();

 

//각 기호의 우선순위 레벨. 곱하기, 나누기 > 더하기, 빼기 > 기타
level.put("*", 3);
level.put("/", 3);
level.put("+", 2);
level.put("-", 2);
level.put("(", 1);

for (Object object : constant) {
if (object.equals("(")) {
stack.push(object);
} else if (object.equals(")")) {
while (!stack.peek().equals("(")) {
Object val = stack.pop();
if (!val.equals("(")) {
result.add(val);
}
}
stack.pop();
} else if (level.containsKey(object)) {
if (stack.isEmpty()) {
stack.push(object);
} else {
if (Double.parseDouble(level.get(stack.peek()).toString()) >= Double.parseDouble(level.get(object).toString())) {
result.add(stack.pop());
stack.push(object);
} else {
stack.push(object);
}
}
} else {
result.add(object);
}
}

while (!stack.isEmpty()) {
result.add(stack.pop());
}

return result;
}

//후위 표기식을 계산
private Double postFixEval(ArrayList expr) {
Stack numberStack = new Stack();
for (Object o : expr) {
if (o instanceof Double) {
numberStack.push(o);
} else if (o.equals("+")) {
num1 = (Double) numberStack.pop();
num2 = (Double) numberStack.pop();
numberStack.push(num2 + num1);
} else if (o.equals("-")) {
num1 = (Double) numberStack.pop();
num2 = (Double) numberStack.pop();
numberStack.push(num2 - num1);
} else if (o.equals("*")) {
num1 = (Double) numberStack.pop();
num2 = (Double) numberStack.pop();
numberStack.push(num2 * num1);
} else if (o.equals("/")) {
num1 = (Double) numberStack.pop();
num2 = (Double) numberStack.pop();
numberStack.push(num2 / num1);
}
}

resultNumber = (Double) numberStack.pop();

return resultNumber;
}

public Double process(String equation) {
ArrayList postfix = infixToPostfix(splitTokens(equation));
Double result = postFixEval(postfix);
return result;
}

public boolean checkNumber(String str) {
char check;

if (str.equals(""))
return false;

for (int i = 0; i < str.length(); i++) {
check = str.charAt(i);
if (check < 48 || check > 58) {
if (check != '.')
return false;
}
}
return true;
}
}

 

설명

각 메소드에 대한 것은 주석을 읽어주세요!

 

크게 복잡한 것은 없습니다. MainActivity에서 사용자의 입력이 기록된 textView의 텍스트를 CalculateHelper로 전달하면, CalculateHelper에서는 그것을 후위표기식으로 변환 후 계산까지 하고 MainActivity로 리턴합니다.

 

궁금하신 점이 있으시다면 댓글로 남겨주세요.

 

 

xml코드

activity_main.xml

 

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1.5"
android:orientation="vertical">

<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1">

<TextView
android:id="@+id/first_textView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="10dp"
android:text=""
android:textSize="30dp" />
</ScrollView>

<TextView
android:id="@+id/second_textView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="10dp"
android:layout_weight="2"
android:text=""
android:textSize="30dp" />

</LinearLayout>

<GridLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:columnCount="4"
android:orientation="horizontal"
android:rowCount="5">

<Button
android:id="@+id/clear"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="C" />

<Button
android:id="@+id/bracket"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="( )" />

<Button
android:id="@+id/percent"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="%" />

<Button
android:id="@+id/div"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="÷" />


<Button
android:id="@+id/num7"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="7" />

<Button
android:id="@+id/num8"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="8" />

<Button
android:id="@+id/num9"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="9" />

<Button
android:id="@+id/mul"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="X" />


<Button
android:id="@+id/num4"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="4" />

<Button
android:id="@+id/num5"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="5" />

<Button
android:id="@+id/num6"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="6" />

<Button
android:id="@+id/sub"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="-" />


<Button
android:id="@+id/num1"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="1" />

<Button
android:id="@+id/num2"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="2" />

<Button
android:id="@+id/num3"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="3" />

<Button
android:id="@+id/add"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="+" />


<Button
android:id="@+id/back"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="back" />

<Button
android:id="@+id/num0"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="0" />

<Button
android:id="@+id/dot"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="." />

<Button
android:id="@+id/equal"
android:layout_rowWeight="1"
android:layout_columnWeight="1"
android:layout_margin="-5dp"
android:text="=" />

</GridLayout>
</LinearLayout>

이번에는 안드로이드 스튜디오를 이용해서 만든 간단한 계산기 앱을 보여드리려고 합니다.

 

원래 부스트코스 에이스 과정 중 기업연계 프로젝트로 진행한 주제였는데, ReactNative로 만들어야 하는 걸 제가 시간이 없는 관계로 이틀 동안 스튜디오로 만들었습니다. (하루 정도 코드를 rn으로 변환해보려고 했는데 쉽지가 않아서 포기했습니다. rn을 안드로이드 코드로 바꾸는 것보다 안드로이드 코드를 rn으로 바꾸는 게 더 어렵다는 자료를 본 거 같아요.)

 

먼저 완성된 것부터 보여드리고 코드로 넘어가겠습니다.

 

 

실행 예시

개요

더하기, 빼기, 곱하기, 나누기, 괄호 등으로 구성된 간단한 계산기 구현

 

구성

다른 앱과 기본 계산기 앱 등을 참고하여 사용자의 입력을 표기할 상단 레이아웃, 계산식에 필요한 20개의 버튼이 있는 하단 레이아웃으로 구성.

 

상단 레이아웃 : 사용자 입력을 표기할 텍스트뷰(+스크롤뷰) + 계산 결과 미리보기를 제공하는 텍스트뷰

하단 레이아웃 : 그리드 레이아웃과 거기에 속한 20개의 버튼

 

코드

MainAcitivity.java

 

package com.example.calculator;

import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;

public class MainActivity extends AppCompatActivity {
CalculateHelper calculateHelper;

boolean isDot;
boolean isBracket;
boolean isPreview;

TextView textView;
TextView textView2;

int size;

String result;

Button num0;
Button num1;
Button num2;
Button num3;
Button num4;
Button num5;
Button num6;
Button num7;
Button num8;
Button num9;

Button add;
Button sub;
Button mul;
Button div;
Button clear;
Button bracket;
Button percent;
Button back;
Button dot;

Button equal;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

calculateHelper = new CalculateHelper();

size = 0;
int number = 25;
int t = String.valueOf(Math.sqrt(number)).length();
Log.d("test", "" + t + " ? " + String.valueOf(Math.sqrt(number)));

isPreview = false;
isBracket = false;
isDot = false;

int[][] test = new int[5][4];
setButton();
setTextView();
}

private void setButton() {
num0 = findViewById(R.id.num0);
num1 = findViewById(R.id.num1);
num2 = findViewById(R.id.num2);
num3 = findViewById(R.id.num3);
num4 = findViewById(R.id.num4);
num5 = findViewById(R.id.num5);
num6 = findViewById(R.id.num6);
num7 = findViewById(R.id.num7);
num8 = findViewById(R.id.num8);
num9 = findViewById(R.id.num9);

add = findViewById(R.id.add);
sub = findViewById(R.id.sub);
mul = findViewById(R.id.mul);
div = findViewById(R.id.div);
clear = findViewById(R.id.clear);
bracket = findViewById(R.id.bracket);
percent = findViewById(R.id.percent);
back = findViewById(R.id.back);
dot = findViewById(R.id.dot);

equal = findViewById(R.id.equal);

num0.setOnClickListener(numClickListener);
num1.setOnClickListener(numClickListener);
num2.setOnClickListener(numClickListener);
num3.setOnClickListener(numClickListener);
num4.setOnClickListener(numClickListener);
num5.setOnClickListener(numClickListener);
num6.setOnClickListener(numClickListener);
num7.setOnClickListener(numClickListener);
num8.setOnClickListener(numClickListener);
num9.setOnClickListener(numClickListener);

add.setOnClickListener(markClickListener);
sub.setOnClickListener(markClickListener);
mul.setOnClickListener(markClickListener);
div.setOnClickListener(markClickListener);
clear.setOnClickListener(markClickListener);
bracket.setOnClickListener(markClickListener);
percent.setOnClickListener(markClickListener);
back.setOnClickListener(markClickListener);
dot.setOnClickListener(markClickListener);

equal.setOnClickListener(markClickListener);
}

//숫자 버튼이 눌렸을 경우
private Button.OnClickListener numClickListener = new View.OnClickListener() {
@Override
public void onClick(View view) {
switch (view.getId()) {
case R.id.num0:
textView.append("0");
break;
case R.id.num1:
textView.append("1");
break;
case R.id.num2:
textView.append("2");
break;
case R.id.num3:
textView.append("3");
break;
case R.id.num4:
textView.append("4");
break;
case R.id.num5:
textView.append("5");
break;
case R.id.num6:
textView.append("6");
break;
case R.id.num7:
textView.append("7");
break;
case R.id.num8:
textView.append("8");
break;
case R.id.num9:
textView.append("9");
break;
}

preview();
}
};

//기호 버튼이 눌렸을 경우
private Button.OnClickListener markClickListener = new View.OnClickListener() {
@Override
public void onClick(View view) {
switch (view.getId()) {
case R.id.add:
textView.append(" + ");
isPreview = true;
break;
case R.id.sub:
textView.append(" - ");
isPreview = true;
break;
case R.id.mul:
textView.append(" * ");
isPreview = true;
break;
case R.id.div:
textView.append(" / ");
isPreview = true;
break;
case R.id.percent:
textView.append(" % ");
isPreview = true;
break;
case R.id.clear:
textView.setText("");
textView2.setText("");

calculateHelper = new CalculateHelper();

isPreview = false;

break;
case R.id.bracket:
if (!isBracket) {
textView.append("( ");
isBracket = true;
} else {
textView.append(" )");
isBracket = false;
}

isPreview = true;

break;
case R.id.back:
size = textView.getText().length();

if (size != 0)
textView.setText(textView.getText().toString().substring(0, size - 1));

if (size > 1) {
if (calculateHelper.checkNumber(textView.getText().toString().substring(size - 2)))
preview();
else {
isPreview = false;
textView2.setText("");
}
}

break;
case R.id.dot:
textView.append(".");
isDot = true;
break;
case R.id.equal:
result = textView.getText().toString();
double r = calculateHelper.process(result);

if (!isDot)
textView.setText(String.valueOf((int) r));
else
textView.setText(String.valueOf(r));

textView2.setText("");
isDot = false;
isPreview = false;
break;
}
}
};

private void preview() {
if (isPreview) {
result = textView.getText().toString();
double r = calculateHelper.process(result);

if (!isDot)
textView2.setText(String.valueOf((int) r));
else
textView2.setText(String.valueOf(r));
}
}

private void setTextView() {
textView = findViewById(R.id.first_textView);
textView2 = findViewById(R.id.second_textView);
}

 

설명

버튼 클릭은 숫자, 기호로 구분되어 동작하며 버튼이 눌렸을 경우 append()를 사용하여 textView에 표시

textView2는 결과 미리보기로 1)숫자 입력 2)기호 입력 3)숫자 입력 순으로 앞에 숫자와 기호가 존재하고 다시 숫자가 입력되는 경우 미리보기를 제공한다. 계산은 CalculateHelper에서 진행 후 리턴.

 

CalculateHelper에 대한 것은 다음 글에서 설명하겠습니다.

안녕하세요~!

 

부스트코스 에이스와 그에 관련된 프로젝트는 끝났지만, 수료를 하기 위해서는 마지막 프로젝트까지 진행해야 합니다.

 

 

프로젝트 개요 (Project Overview)

 

메뉴 화면이 보일 때 애니메이션을 적용하고 스플래시 화면을 추가해봅니다.

 

 

먼저 프로젝트 개요입니다. 애니메이션 적용과 스플래쉬 화면 추가. 간단하죠?

 

참고로 스플래시 화면은 앱이 실행되기 전에 보였다가 사라지는 화면을 말합니다. 앱에 필요한 데이터를 로딩하는 시간을 벌 수도 있고 세련된 디자인을 사용한다면 사용자의 시선을 끌 수도 있겠네요.

 

 

요구사항 (Requirements)

**부스트코스 학습을 기반으로 진행하는 프로젝트이기에 JAVA언어를 활용하여 개발하여야 합니다.

애니메이션과 스플래시

1) 애니메이션 동작 기능을 정의하고 정렬 버튼에 적용합니다.

2) 영화목록의 상단 정렬 버튼을 누르면 정렬 메뉴가 보이도록 하되 메뉴를 위한 뷰가 아래쪽으로 애니메이션되어 표시되도록 합니다.

  • 정렬 메뉴의 화면 레이아웃은 아래 그림을 참조하세요.

3) 정렬 메뉴 중 하나를 선택하면 메뉴를 위한 뷰가 위쪽으로 애니메이션된 후 사라지도록 합니다.

4) 앱을 시작했을 때 스플래시 화면이 보였다가 영화목록 화면으로 전환되도록 합니다.

 

 

요구사항 1~4에 대한 내용은 강의내용에 있어서 그대로 따라하면 됩니다.

 

저희가 고민해야 할 부분은 정렬 버튼에 따라서 영화 목록을 재배치하는 것이죠. 일단 완성된 것을 보여드릴게요.

오른쪽 상단에 있는 [좌우 화살표 + 텍스트(예매율순)]이 정렬 버튼입니다.

 

버튼을 눌렀을 때 아래로 펼쳐지는 애니메이션 효과를 적용해야하며,

정렬 방식을 선택하면 그에 맞춰서 영화 순서를 재배치합니다.

 

왼쪽부터 순서대로 보시면, 예매율순으로 배된 영화들이 큐레이션 순으로 재배치된 것을 알 수 있습니다.

 

 

>정렬메뉴 구현

 

사실 여기서도 좀 헷갈렸습니다. 정렬 버튼은 툴바에 있는데 정렬 버튼을 눌렀을 때 목록 역시 툴바 안에서 동작하게 하려고 했는데, 제가 못한 건지 모르겠지만 목록에 아이콘과 텍스트를 동시에 줄 수가 없었습니다.

 

결국 한참 고민한 끝에 .setVisibility을 사용해 버튼이 눌리기 전까지 투명, 눌렀을 때 펼치듯 나타나는 것으로 구현했습니다. 물론 이에 해당하는 View는 툴바가 아니라 외부 레이아웃에 구현했습니다.

 

>영화 순서 재배치

 

저희는 인터넷이 되는 상태라면 데이터베이스에 영화 정보를 저장하는 것을 이전에 진행했습니다. 저는 그걸 이용했습니다. 원리는 간단합니다. 원래 있던 뷰페이저를 초기화하고 원하는 데이터 순으로 재배치합니다.

 

큐레이션 버튼이 눌렸다면, 그에 해당하는 메소드가 동작하고 일단 뷰페이저(영화목록은 뷰페이저)를 초기화합니다.

 

그 후 데이터베이스에서 데이터를 받아오고 큐레이션 순으로 정렬, 그 순서대로 뷰페이저를 다시 채워줍니다.

 

>스플래시 화면

 

방법 1 : AndroidManifest.xml에서 시작 액티비티를 Main이 아닌 Splash로 변경

방법 2 : Main 액티비티에서 곧바로 Splash 액티비티 실행 후 종료

 

저는 1번을 사용했습니다

 

 

모든 요구사항을 충족했으니 리뷰 결과를 볼까요?

 

PASS!

 

이것으로 안드로이드 프로그래밍을 수료하게 되었습니다.

 

모든 코드를 공유했으면 좋았을 텐데, 부스트코스 측에서 금지했기 때문에 어쩔 수가 없습니다. 제 설명이라도 도움이 되었으면 좋겠네요.

드디어 마지막 미션입니다.

프로젝트에는 아직 하나가 더 있지만 부스트코스 에이스에서 진행하는 미션은 여기까지입니다.

 

나머지 하나는 나중에 혼자서 해야겠네요.

 

요구사항입니다.

 

간단히 요약하면 가로 리사이클러뷰 안에 사진과 동영상을 넣으면 됩니다. 이런 식으로요.

 

여기서 주의할 점은

초창기에 배웠던 리스트뷰가 아니라 리사이클러뷰를 사용하다는 점,

갤러리에 있는 사진을 눌렀을 때 사진이 있는 화면이 뜨고 확대, 축소가 가능해야 한다는 점,

갤러리에 있는 동영상을 눌렀을 때 유튜브로 연결되어 재생된다는 점입니다.

 

 

1) 갤러리 추가

 

원래 있던 상세화면에 리사이클러뷰를 중간에 넣어 구성했습니다.

 

리사이클러뷰는 레이아웃 매니저를 set하여 모양을 만들게 되는데

 

평범한 세로 스크롤을 사용하면 아무런 설정도 하지 않은 LinearLayoutManager을,

가로 스크롤을 사용하기 위해서는 LinearLayout.HORIZONTAL 을 설정해야 합니다.

 

이외에도 그리드나 불규칙 매니저를 사용하기도 한다네요.

 

 

이후 서버에서 전달되는 사진과 동영상 정보를 이용하여, 아이템과 어뎁터 클래스 등을 만듭니다.

댓글 목록을 만들 때와 다르지 않습니다. 거의 유사하고 아이템의 변수가 다를 뿐입니다.

 

그렇기 때문에 어려운 점은 없었습니다. 굳이 어려운 게 있었다면 아이템 간의 간격 문제였습니다.

 

데코레이션 클래스를 만들어서 설정할 수도 있었으나, 저는 만들기만 하고 사용하지 않았습니다.

 

 

 

2) 사진을 눌렀을 때 사진 화면으로 전환

 

화면 전환은 간단합니다. 액티비티 간의 이동이기 때문에 사진을 보는 xml과 java를 만들어 거기로 연결하면 되는 거죠.

단, 리사이클러뷰의 아이템을 터치하는 것이기 때문에 각 아이템을 눌렀을 때 그에 알맞은 화면으로 전환되도록 해야합니다.

 

이를 위해 어뎁터에 클릭 메소드를 만들어서 사용했습니다.

 

또한 사진 화면에서는 사진 확대와 축소를 해야하기 때문에 외부 주소에서 관련 라이브러리를 빌려왔습니다.

 

 

 

3) 갤러리에서 동영상을 눌렀을 때 동영상 재생

 

동영상 재생은 앱에서 진행하지 않고 관련 링크로 전환되도록 하였습니다.

이건 인텐트를 조금 다르게 하는 것으로 바로 이동시키더군요.

 

new Intent(Intent.ACTION_VIEW, Uri.parse(주소));

 

이런 식으로요.

 

 

 

이렇게 요구사항을 전부 완수하고 이전 프로젝트에서 리뷰받은 걸 토대로 새로 정리한 후 제출했습니다.

 

결과는 어떻게 됐을까요??

PASS입니다~

 

제가 이걸 하면서 느낀 게 어떤 상황에 대해서 응용하거나 어떤 방식으로 동작하도록 만들지에 대한

아이디어나 방법 같은 건 어렵지 않은데,

 

xml과 같은 걸 다루는 게 오히려 낯설 게 느껴지더군요.

 

코드 리뷰를 보고 부족한 점을 공부해야겠습니다.

 

 

 

이번이 마지막 프로젝트였는데요, 다음에는 기업 연계 프로젝트에 대한 걸 공유하도록 하겠습니다.

 

감사합니다.

 

안녕하세요~! 이번 프로젝트의 주제는 데이터베이스입니다.

 

처음 접한다면 다소 어색하고 어려울 수 있는 부분인데, 예전에 학교 강의를 들었던 게 여기서 도움이 되네요ㅎ

 

 

 

1, 2) 데이터베이스 만들기와 데이터 삽입

 

서버에서 3가지 정보(영화목록, 영화상세정보, 영화코멘트)를 받아왔으니 그것들을 각각 저장해야 합니다.

 

하지만 그 전에 데이터베이스를 만드는 게 선행되어야 하겠죠.

 

그리고 3가지 정보들은 데이터베이스의 테이블에 저장합니다.

 

 

데이터베이스를 이용하는 방법은 안드로이드 자바 코드에서 SQLiteDatabas, openOrCreateDatabase 를 사용하면 됩니다. 생성, 오픈, 업그레이드 등의 기타 편의성을 위해서 헬퍼 클래스를 만들어 사용하기도 하는데, 이번 프로젝트에는 필요하지 않아 보여 사용하지 않았습니다.

 

openDatabase, createTable, insertData 등. 데이터베이스를 열고 테이블을 생성하고 데이터를 삽입하는 기본적인 메소드와 함께 제가 필요했던 메소드를 몇 개 추가했습니다. (예를 들면 해당 테이블에 데이터가 있는지에 대한)

 

 

3) 인터넷 연결상태에 따라 화면에 데이터 표시

 

저는 인터넷 연결상태를 세 가지로 구분했습니다.

 

연결되지 않음, 3G/LTE, WIFI

 

연결되지 않았다면 데이터베이스의 데이터를 기본으로 화면을 구성하여 보여주고, 인터넷이 연결되지 않았다는 토스트 메시지를 출력했습니다. 다만 사용자가 이전에 접속하지 않은 화면에 대해서는, DB에 데이터가 없을 테니 '저장된 데이터가 없습니다.'라는 기능도 추가했습니다.

 

이를 위해서 해당 액티비티나 프래그먼트(각각 다른)에 대한 데이터의 유무를 검색하는 메소드를 사용했습니다.

 

 

3G/LTE로 접속됐다면 과도한 요금이 청구되거나 원하지 않는 데이터를 소모할 수 있습니다. 그래서 사용자로 하여금 계속 진행할 것인지 멈출 것인지에 대한 걸 AlertDialog로 보여주고 예, 아니요로 진행 혹은 앱 종료를 동작시켰습니다.

 

 

WIFI로 연결되었다면 그냥 진행하도록 하였죠.

 

추가적으로 몇몇 수정을 거친 후에 월요일에 제출했습니다.

 

그리고

 

 

부족한 점이 있었지만 리뷰어 님이 잘 봐주신 듯 하네요.

 

꼼꼼히 한다고 했는데 역시 부족한 점이 아직 많은 거 같습니다ㅠ.ㅠ

+ Recent posts