달력

5

« 2024/5 »

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
반응형

일전에 이미지를 특정 영역에서 크기와 위치가 변경될 수 있는 기능을 만들어야 하는 일이 있었다.
당시 pub.dev에서 해당 기능이 구현되어 있는 패키지를 찾아보았지만 딱 맞는 것이 없었다.
 
결국 제스처로 크기(scale)와 x축 y축을 통해 위치를 자유롭게 조정할 수 있는 matrix_gesture_detector를 발견해서
아래 소스를 작성하여 원하는 기능을 구현할 수 있었다.
 
1. yaml을 작성한다.

path_provider: 2.0.1
matrix_gesture_detector: 0.1.0

 
2. moveImage.dart를 작성한다.

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:matrix_gesture_detector/matrix_gesture_detector.dart';
import 'package:path_provider/path_provider.dart';

class moveImage extends StatefulWidget {
  String path;
  String fileType;
  double width;
  double height;

  @override
  _moveImageState createState() => _moveImageState();

  moveImage({
    @required this.path,
    this.fileType = "storage",
    this.width = 450,
    this.height = 250,
    Key key})
      : super(key: key);
}

class _moveImageState extends State<moveImage> {
  Map<String, dynamic> item = {};
  
  @override
  void initState() {
    super.initState();
    setImageFileInfo();
  }

  @override
  Widget build(BuildContext context) {
    return Container(
        width: widget.width,
        height: widget.height,
        child: moveImage(this.item)
    );
  }

  Widget moveImage(item) {
    return new FutureBuilder<File>(
        future: item["fileType"] == "assets" ? getImageFileFromAssets(item["path"]) : getFile(item["path"]),
        builder: (context, snapshot) {
          if (snapshot.data == null || item["matrix"] == null) {
            return Container();
          } else {
            return MatrixGestureDetector(
                shouldRotate: false,
                shouldScale: true,
                onMatrixUpdate: (m, tm, sm, rm) {
                  setState(() {
                    var maxWidth = widget.width;
                    var maxHeight = widget.height;
                    var width = item["width"];
                    var height = item["height"];

                    Matrix4 matrix = Matrix4.identity();
                    matrix = MatrixGestureDetector.compose(item["matrix"], tm, sm, rm);
                    Offset translation = MatrixGestureDetector.decomposeToValues(matrix).translation;
                    Size changeSize = MatrixUtils.transformRect(matrix, item["transformKey"].currentContext.findRenderObject().paintBounds).size;

                    var initialScale = item["initialScale"];
                    var changeWidth = changeSize.width;
                    var changeHeight = changeSize.height;

                    var chkRatio = changeHeight/maxHeight;

                    if (initialScale / 2.0 <= chkRatio && chkRatio <= initialScale + 2.0) {
                      Offset currentTranslation = MatrixGestureDetector.decomposeToValues(item["matrix"]).translation;

                      var fullHeight = height * maxWidth / width;
                      var fullChangeWidth = width * changeHeight / height;
                      var fullChangeHeight = height * changeWidth / width;
                      if (fullHeight >= maxHeight) {
                        if (translation.dy > 0 || translation.dx > 0
                            || changeHeight + translation.dy < maxHeight || fullChangeWidth + translation.dx < maxWidth) {
                          var dy = translation.dy, dx = translation.dx;

                          if (changeHeight >= maxHeight) {
                            if (translation.dy > 0) {
                              dy = 0.0;
                            }
                            if (changeHeight + translation.dy < maxHeight) {
                              if (changeHeight > maxHeight) {
                                dy = maxHeight - changeHeight;
                              } else {
                                dy = currentTranslation.dy;
                              }
                            }
                          } else {
                            if (maxHeight < changeHeight + translation.dy) {
                              dy = maxHeight - changeHeight;
                            } else if (0.0 > currentTranslation.dy + translation.dy) {
                              dy = 0.0;
                            } else {
                              dy = translation.dy;
                            }
                          }

                          if (fullChangeWidth >= maxWidth) {
                            if (translation.dx > 0){
                              dx = 0.0;
                            }
                            if (fullChangeWidth + translation.dx < maxWidth) {
                              if (fullChangeWidth > maxWidth) {
                                dx = maxWidth - fullChangeWidth;
                              } else {
                                dx = currentTranslation.dx;
                              }
                            }
                          } else {
                            if (maxWidth < fullChangeWidth + translation.dx) {
                              dx = maxWidth - fullChangeWidth;
                            } else if (0.0 > currentTranslation.dx + translation.dx) {
                              dx = 0.0;
                            } else {
                              dx = translation.dx;
                            }
                          }

                          tm = translateMatrix(new Offset(dx - currentTranslation.dx, dy - currentTranslation.dy));
                          matrix = MatrixGestureDetector.compose(item["matrix"], tm, sm, rm);
                        }
                      } else {
                        if (translation.dy > 0 || translation.dx > 0
                            || fullChangeHeight + translation.dy < maxHeight || changeWidth + translation.dx < maxWidth) {
                          var dy = translation.dy, dx = translation.dx;

                          if (fullChangeHeight >= maxHeight) {
                            if (translation.dy > 0) {
                              dy = 0.0;
                            }
                            if (fullChangeHeight + translation.dy < maxHeight) {
                              if (fullChangeHeight > maxHeight) {
                                dy = maxHeight - fullChangeHeight;
                              } else {
                                dy = currentTranslation.dy;
                              }
                            }
                          } else {
                            if (maxHeight < fullChangeHeight + translation.dy) {
                              dy = maxHeight - fullChangeHeight;
                            } else if (0.0 > currentTranslation.dy + translation.dy) {
                              dy = 0.0;
                            } else {
                              dy = translation.dy;
                            }
                          }

                          if (changeWidth >= maxWidth) {
                            if (translation.dx > 0){
                              dx = 0.0;
                            }
                            if (changeWidth + translation.dx < maxWidth) {
                              if (changeWidth > maxWidth) {
                                dx = maxWidth - changeWidth;
                              } else {
                                dx = currentTranslation.dx;
                              }
                            }
                          } else {
                            if (maxWidth < changeWidth + translation.dx) {
                              dx = maxWidth - changeWidth;
                            } else if (0.0 > currentTranslation.dx + translation.dx) {
                              dx = 0.0;
                            } else {
                              dx = translation.dx;
                            }
                          }

                          tm = translateMatrix(new Offset(dx - currentTranslation.dx, dy - currentTranslation.dy));
                          matrix = MatrixGestureDetector.compose(item["matrix"], tm, sm, rm);
                        }
                      }
                      item["matrix"] = matrix;
                    }
                  });
                }, child: new Transform( key: item["transformKey"], transform: item["matrix"],
                child: new Image.file(snapshot.data, fit:BoxFit.contain, alignment: Alignment.topLeft,)
              )
            );
          }
        });
  }

  Matrix4 translateMatrix(Offset translation) {
    var dx = translation.dx;
    var dy = translation.dy;

    return Matrix4(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, dx, dy, 0, 1);
  }

  Future setImageFileInfo() async {
    this.item["path"] = widget.path;
    this.item["fileType"] = widget.fileType;

    if (this.item["transformKey"] == null) {
      this.item["transformKey"] = new GlobalKey();
    }

    File image;
    if (this.item["fileType"] == "assets") {
      image = await getImageFileFromAssets(this.item["path"]);
    } else {
      image = new File(this.item["path"]);
    }
    var decodedImage = await decodeImageFromList(image.readAsBytesSync());
    this.item["width"] = decodedImage.width.toDouble();
    this.item["height"] = decodedImage.height.toDouble();

    var maxWidth = widget.width;
    var maxHeight = widget.height;
    var width = this.item["width"];
    var height = this.item["height"];

    var widthRatio = (maxWidth/width);
    var heightRatio = (maxHeight/height);
    var initialScale = (heightRatio/widthRatio);

    var fullHeight = height * maxWidth / width;
    if (fullHeight >= maxHeight) {
      initialScale = (widthRatio/heightRatio);
    }

    setState(() {
      this.item["initialScale"] = initialScale;
      this.item["matrix"] = Matrix4.identity().scaled(initialScale);
    });
  }
}

Future<File> getImageFileFromAssets(String path) async {
  final byteData = await rootBundle.load('assets/$path');

  String tmpPath = '${(await getTemporaryDirectory()).path}/$path';

  final file = File(tmpPath);
  await file.create(recursive: true);
  await file.writeAsBytes(byteData.buffer.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes));

  return file;
}

Future<File> getFile(String path) async {
  return File(path);
}

 
3. 작성한 moveImage.dart를 widget으로 사용할 화면의 dart 소스에서 불러온다.

moveImage(
  path: "test.jpeg",
  fileType: "assets",
),

 
위 예시는 assets에 있는 이미지 기준으로 작성되어 있으며
휴대폰에 있는 사진첩에서 이미지를 가져온다면(권한 관련 처리도 필수!)
fileType를 없애고 path에 이미지의 실제 경로(사진 선택 기능을 통해 알게 된 경로)를 넣어주면 된다.
 
 

 
 
참고 : https://pub.dev/packages/matrix_gesture_detector

반응형
:
Posted by 리샤씨
반응형

최근 Flutter TextField에서 숫자 입력 시 천 단위 마다 콤마를 표시되도록 해야하는 일이 생겼는데

이게 생각보다 간단하지 않았다. 

일단 숫자 입력만 되도록 제한하고 천 단위 마다 콤마 표시하는 것 자체는 크게 어렵지 않았는데,

콤마를 표시하면 그것도 자리 차지를 하게 되어 중간에서 입력/삭제 후 자연스러운 커서 위치를 잡기가 어려웠지만

규칙을 찾아 수식을 작성해서 해결했다.

 

완성된 코드는 다음과 같다.

import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';

class ThousandCommaTextField extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Color(0xffffffff),
      body: SafeArea(
        child: Container(
          padding: EdgeInsets.all(10),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              TextField(
                keyboardType: TextInputType.number,
                textAlign: TextAlign.right,
                inputFormatters: <TextInputFormatter>[
                  FilteringTextInputFormatter.allow(RegExp(r'[0-9]')),
                  FilteringTextInputFormatter.digitsOnly,
                  ThousandCommaInputFormatter()
                ],
              )
            ],
          )
        )
      ),
    );
  }
}

String thousandComma(dynamic val, {String defaultVal = ""}) {
  NumberFormat numberFormat = NumberFormat('#,###', "ko_KR");
  if (val != null && val != "") {
    if (val is int || val is double) {
      return numberFormat.format(val);
    } else if (double.tryParse(val) != null) {
      return numberFormat.format(double.parse(val));
    } else if (int.tryParse(val) != null) {
      return numberFormat.format(int.parse(val));
    }
  }

  return defaultVal;
}

class ThousandCommaInputFormatter extends TextInputFormatter {
  TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
    String oldNum = oldValue.text.replaceAll(",", "");
    String newNum = newValue.text.replaceAll(",", "");

    String newText = thousandComma(newValue.text, defaultVal: "0");

    // 다음 커서 위치 잡기
    int selectionIndex = ((newValue.text.length -1) / 3).floor() // 전체 ,(comma)의 수
                        - (((newValue.text.length - newValue.selection.end)-1) / 3).floor() //현재 커서 위치를 기준으로 오른쪽에 있는 ,(comma)의 수
                        + newValue.selection.end;

    if (selectionIndex > newText.length) {
      selectionIndex = newText.length;
    }

    if (oldNum == newNum) {//,(comma) 뒤에 커서놓고 삭제 시
      //1,234,567에서 4,뒤(커서 위치 6)에서 삭제하면 123,567이 되므로 1뒤의 ,와 4가 같이 없어져서 커서 위치가 2만큼 왼쪽으로 이동해야 한다. (최종 커서 위치 4가 되야함)
      //98,765에서 8,뒤(커서 위치 3)에서 삭제하면 9,765가 되므로 8만 없어져서 커서 위치가 1만큼 왼쪽으로 이동해야 한다. (최종 커서 위치 2가 되야함)
      selectionIndex = selectionIndex - (newText.replaceAll(",", "").length % 3 == 1 ? 2 : 1);
      newText = newValue.text.substring(0, newValue.selection.end - 1) + newValue.text.substring(newValue.selection.end);
      newText = thousandComma(newText, defaultVal: "0");
    }

    return newValue.copyWith(
        text: newText,
        selection: TextSelection.collapsed(
            offset: selectionIndex));
  }
}

 

 

참고 출처:

https://stackoverflow.com/questions/49577781/how-to-create-number-input-field-in-flutter

https://stackoverflow.com/questions/61866207/thousand-separator-without-decimal-separator-on-textformfield-flutter

반응형
:
Posted by 리샤씨
반응형

가끔가다, 파일 첨부 시 첨부된 파일의 용량을 보여줘야 할 때가 있다.

보통 파일 첨부 시 알 수 있는 것은 로우한 파일 크기인데,

필요한 건 해당 값을 1024로 나눈 값인 KB/MB/GB 등등이다.

 

처음엔 JS에서만 사용했었는데 최근 flutter에서도 필요하게 되어 두 가지 버전을 공유한다.

 

javascript 버전

function spaceFormat(spaceValue, decimalPoint){
	var fileSizeNmList = ["Byte", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
	var fileSizeNmIndex = 0;
	
	while (spaceValue > 1024 && fileSizeNmIndex < fileSizeNmList.length) {
		spaceValue /= 1024;
		fileSizeNmIndex++;
	}
	
	if (spaceValue.toFixed != undefined) {
		spaceValue = spaceValue.toFixed(decimalPoint);
	}
	return spaceValue + fileSizeNmList[fileSizeNmIndex];
}

 

flutter 버전

String spaceFormat(double spaceValue, int decimalPoint){
  List<String> fileSizeNmList = ["Byte", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
  int fileSizeNmIndex = 0;

  while (spaceValue > 1024 && fileSizeNmIndex < fileSizeNmList.length) {
    spaceValue /= 1024;
    fileSizeNmIndex++;
  }

  return spaceValue.toStringAsFixed(decimalPoint) + fileSizeNmList[fileSizeNmIndex];
}

 

반응형
:
Posted by 리샤씨
2022. 8. 3. 11:22

Flutter에서 Flick 화면 만들기 개발/Flutter2022. 8. 3. 11:22

반응형

일전에 Flick(한 방향으로 미는 것을 말한다고 한다. 참고 : https://thankee.tistory.com/117) 동작을 하는 화면을 요청 받은 적이 있었다.

그때는 한 방향으로 밀면 화면이 밀은 그대로 높이를 이동시키는 화면이었는데

최근 또 다른 프로젝트를 하는데 비슷한 동작을 하는 화면이 있어서

일전에 만든 소스를 활용해서 좀 더 범용성 있게 작성해보았다.

 

이번에 필요한 화면은 똑같은 동작으로 화면의 높이를 변화시키지만,

고정적인 세 가지 위치(최저/중간/최대)로만 이동이 가능한 기능과

최대 크기(화면 전체였다)로 키웠을 때 그림자가 사라지게 하는 기능이 필요해서 그 부분만 추가로 작업해주었다.

 

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

class flickFrameCommon extends StatefulWidget {
  var mode; //soft, hard
  bool enabled;

  double height;
  double minHeight;
  double maxHeight;
  double middleHeight;

  EdgeInsets padding;
  Function changeHeight;//높이 값이 변화할 때 마다 flickFrameCommon를 호출한 곳에서 높이를 돌려 받을 수 있음
  Widget child;

  flickFrameCommon({
    Key key,
    this.mode = "soft",
    this.enabled = true,
    this.height = 52.0,
    this.minHeight,
    this.maxHeight,
    this.middleHeight,
    this.padding,
    this.changeHeight,
    @required this.child,
  }) : super(key: key);

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

class _flickFrameCommonState extends State<flickFrameCommon> {
  int shadowAlpha = 125;
  double prevHeight;
  double Sensitivity = 100; //감도

  @override
  void initState() {
    super.initState();
    prevHeight = widget.height;

    if (widget.minHeight == null) {
      if (30 < widget.height && widget.height < 118.7 ) {
        widget.minHeight = widget.height;
      } else {
        widget.minHeight = 118.7;
      }
    }

    if (widget.mode != "hard" && widget.mode != "soft") {
      throw ArgumentError("The mode is soft or hard");
    }

    if (widget.height < widget.minHeight) {
      throw ArgumentError("The height is greater than the minimum value");
    }

    if (widget.maxHeight == null) {
      if (333.7 < widget.height && widget.height <= Get.height) {
        widget.maxHeight = widget.height;
      } else {
        widget.maxHeight = 333.7;
      }
    }

    if (widget.height > widget.maxHeight) {
      throw ArgumentError("The height is less than the maximum value");
    }

    if (widget.padding == null) {
      widget.padding = EdgeInsets.only(left: 18.3, right: 18.3);
    }

    if (widget.mode == "hard") {
      if (widget.middleHeight == null) {
        throw ArgumentError("middleHeight is a must when in hard mode");
      }
      setHardHeight();
    }
  }

  @override
  Widget build(BuildContext context) {
    if (widget.height == widget.maxHeight) {
      shadowAlpha = 0;
    } else {
      shadowAlpha = 125;
    }

    return Positioned(
      bottom: 0,
      child: AnimatedContainer(
        width: Get.width,
        height: widget.height,
        duration: Duration(milliseconds: 500),
        padding: widget.padding,
        decoration: BoxDecoration(
          borderRadius: BorderRadius.only(topLeft: Radius.circular(24), topRight: Radius.circular(24)),
          boxShadow: [BoxShadow(
              color: Color.fromARGB(shadowAlpha, 0, 0, 0),
              offset: Offset(0,3),
              blurRadius: 5,
              spreadRadius: 0,
          )],
          color: const Color(0xffffffff),
        ),
        child: Listener(
            behavior: HitTestBehavior.opaque,
            onPointerMove: (details){
              if (widget.enabled) {
                setState(() {
                //이동된 거리(details.delta.dy)는 위쪽으로 이동하면 -로 나오므로 현재 높이에 -를 해주면 +가 되어 높이가 커짐
                  if (widget.height - details.delta.dy < widget.minHeight) {//이동된 지점(현재 높이 - 이동된 거리)이 최저 높이보다 낮으면
                    widget.height = widget.minHeight;//높이를 최저 높이로 변경
                  } else
                  if (widget.height - details.delta.dy > widget.maxHeight) {//이동된 지점(현재 높이 - 이동된 거리)이 최대 높이보다 높으면
                    widget.height = widget.maxHeight;//높이를 최대 높이로 변경
                  } else {//최대와 최저 중간값이면 
                    widget.height = widget.height - details.delta.dy;//높이를 이동된 지점으로 변경
                  }
                });
              }
            },
            onPointerDown: (details) {//Pointer 이벤트가 시작할 때
              if (widget.enabled) {
                prevHeight = widget.height;//이전 높이를 저장
              }
            },
            onPointerUp: (details) {//Pointer 이벤트가 끝날 때
              if (widget.enabled) {
              	if (widget.mode == "hard") {
                  setState(() {
                    setHardHeight();//hard mode이면 최종 높이를 최저/중간/최대로 지정
                  });
                }

                if (widget.changeHeight != null) {
                  widget.changeHeight(widget.height);
                }
              }
            },
            child: Column(children: [
            Expanded(child: widget.child)
          ]),
        ),
      ),
    );
  }

  setHardHeight () {
    var minH_d = (widget.height - widget.minHeight).abs();//이벤트가 끝난 지점과 최저 높이의 거리 계산
    var maxH_d = (widget.height - widget.maxHeight).abs();//이벤트가 끝난 지점과 최대 높이의 거리 계산
    var middleH_d = (widget.height - widget.middleHeight).abs();//이벤트가 끝난 지점과 중간 높이의 거리 계산

    var direction = widget.height - prevHeight;

    if (prevHeight == widget.minHeight) {//이전 높이가 최저 높이였을 때
      if (direction > Sensitivity) {//감도보다 위쪽으로 이동한 거리가 높으면
        if (maxH_d < middleH_d) {//최대 높이의 거리가 중간 높이 거리보다 가까우면
          widget.height = widget.maxHeight;//현재 높이를 최대 높이로 변경
        } else {
          widget.height = widget.middleHeight;//현재 높이를 중간 높이로 변경
        }
      } else {
        widget.height = widget.minHeight;//감도가 낮으면 원래 높이로 이동
      }
    } else if (prevHeight == widget.maxHeight) {//이전 높이가 최대 높이였을 때
      if (direction < -1 * Sensitivity) {//감도보다 아래쪽으로 이동한 거리가 높으면
        if (minH_d < middleH_d) {//최저 높이의 거리가 중간 높이 거리보다 가까우면
          widget.height = widget.minHeight;//현재 높이를 최저 높이로 변경
        } else {
          widget.height = widget.middleHeight;//현재 높이를 중간 높이로 변경
        }
      } else {
        widget.height = widget.maxHeight;//감도가 낮으면 원래 높이로 이동
      }
    } else {
      if (direction > Sensitivity) {//감도보다 위쪽으로 이동한 거리가 높으면
        widget.height = widget.maxHeight;//현재 높이를 최대 높이로 변경
      } else if (direction < -1 * Sensitivity) {//감도보다 아래쪽으로 이동한 거리가 높으면
        widget.height = widget.minHeight;//현재 높이를 최저 높이로 변경
      } else {//감도가 낮으면 원래 높이로 이동
        widget.height = widget.middleHeight;//감도가 낮으면 원래 높이로 이동
      }
    }
  }
}

 

크게 soft(기본값) / hard mode가 있는데 soft mode가 이전에 사용했던 최저/최대 높이 내에서 자유롭게 움직일 수 있는 방식이고

hard mode가 이번에 새로 개발한 최저/중간/최고 높이로 이동하는 방식이다.

 

get은 3.26.0 버전을 사용하였고

Get.height와 Get.wight 현재 디바이스의 높이/넓이를 말한다.

다른 방식으로 높이와 넓이를 구할 수 있으면 사용하지 않아도 된다.

 

ex) 

import 'dart:async';

import 'package:blog/widget/flick_frame_common.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';

class Test extends StatefulWidget {
  @override
  _TestState createState() => _TestState();
}

class _TestState extends State<Test> {
  double realMaxHeight = Get.height;
  double height;
  double minHeight = 52.0;
  double middleHeight = 296.0;

  @override
  void initState() {
    super.initState();

    height = minHeight;

    Future.delayed(Duration.zero, () async {
      setState(() {
        realMaxHeight = Get.height
            - MediaQuery.of(context).padding.top
            - MediaQuery.of(context).padding.bottom;
      });
    });
  }

  @override
  void dispose() {
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Color(0xffffffff),
      body: SafeArea(
        child: Container(
          width: Get.width,
          height: Get.height,
          child: Stack(children: [
            Container(
              width: Get.width,
              height: Get.height,
              alignment: Alignment.center,
              child: Text("Content Area"),
            ),
            flickFrameCommon(
              mode: "hard",
              height: height,
              padding: EdgeInsets.only(top: 20),
              minHeight: minHeight,
              middleHeight: middleHeight,
              maxHeight: realMaxHeight,
              changeHeight: (height) {
                setState(() {
                  this.height = height;
                });
              },
              child: Column(
                children: [
                  Text("Flick Frame Area")
                ],
              ),
            )
          ]),
        )
      ),
    );
  }
}

 

왼쪽 부터 순서대로 Filck Frame이 최저/중간/최고 높이일 때 화면

반응형
:
Posted by 리샤씨
반응형

오늘 Flutter로 개발된 어플리케이션의 입력 창에서 길게 누를 때 표시되는

자르기/복사/붙여넣기/모두선택(Cut / Copy / Paste / Select all) 한국어 변경 적용을 요청받았다

다행히도 어렵지 않게 한국어 적용이 가능해서 방법을 공유하겠다.

 

1. pubspec.yaml을 수정한다.

dependencies:
  flutter:
    sdk: flutter
  flutter_localizations: # Add this line
    sdk: flutter         # Add this line

 

2. main.dart를 수정한다.

import 'package:flutter_localizations/flutter_localizations.dart';

...

return const MaterialApp(
  title: 'Localizations Sample App',
  localizationsDelegates: [
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
    GlobalCupertinoLocalizations.delegate,
  ],
  supportedLocales: [
    Locale('en', ''),
    Locale('ko', ''),
  ],
  home: MyHomePage(),
);

...

 

 

참고 출처

- https://stackoverflow.com/questions/52100400/flutter-how-to-change-tooltip-name-of-paste-on-textfield-to-devices-language

- https://docs.flutter.dev/development/accessibility-and-localization/internationalization

반응형
:
Posted by 리샤씨
반응형

업데이트 체크를 new_version 패키지로 잘 하고 있었는데 어느 순간 아래와 같이

Bad state: No element Exception이 발생하면서 정상 작동되지 않았다.

E/flutter ( 5782): [ERROR:flutter/lib/ui/ui_dart_state.cc(186)] Unhandled Exception: Bad state: No element
E/flutter ( 5782): #0      ListMixin.firstWhere (dart:collection/list.dart:167:5)
E/flutter ( 5782): #1      NewVersion._getAndroidStoreVersion (package:new_version/new_version.dart:152:51)

 

확인 결과,

Future<VersionStatus?> _getAndroidStoreVersion(
      PackageInfo packageInfo) async {
    final id = androidId ?? packageInfo.packageName;
    final uri =
        Uri.https("play.google.com", "/store/apps/details", {"id": "$id"});
    final response = await http.get(uri);
    if (response.statusCode != 200) {
      debugPrint('Can\'t find an app in the Play Store with the id: $id');
      return null;
    }
    final document = parse(response.body);

    final additionalInfoElements = document.getElementsByClassName('hAyfc');
    final versionElement = additionalInfoElements.firstWhere(
      (elm) => elm.querySelector('.BgcNfc')!.text == 'Current Version',
    );
    final storeVersion = versionElement.querySelector('.htlgb')!.text;

    final sectionElements = document.getElementsByClassName('W4P4ne');
    final releaseNotesElement = sectionElements.firstWhereOrNull(
      (elm) => elm.querySelector('.wSaTQd')!.text == 'What\'s New',
    );
    final releaseNotes = releaseNotesElement
        ?.querySelector('.PHBdkd')
        ?.querySelector('.DWPxHb')
        ?.text;

    return VersionStatus._(
      localVersion: packageInfo.version,
      storeVersion: storeVersion,
      appStoreLink: uri.toString(),
      releaseNotes: releaseNotes,
    );
  }

위의 소스에서 보이듯이 new_version에서 Android는 https://play.google.com/store/apps/details 페이지로 접속해서

hAyfc 클래스를 가져와서 현재 스토어에 올라온 버전이 무엇인지 확인하는 과정이 있는데 

플레이 스토어가 리뉴얼 하면서 그냥 https://play.google.com/store/apps/details?id=com.nhn.android.search로 접속하면 

hAyfc 클래스가 없기 때문에 정상 작동은 커녕 오류를 발생하는 것으로 보였다.

 

이전과 같은 페이지를 보려면 다음과 같이 파라메터에 gl=us를 추가로 보내주면

(https://play.google.com/store/apps/details?id=com.nhn.android.search&gl=us)

hAyfc 클래스가 존재해서, 위 소스의 5번째 줄에 아래 처럼 코드를 수정하면 정상 동작하는 것을 확인하였다.

Uri.https("play.google.com", "/store/apps/details", {"id": "$id", "gl": "US"});

 

해당 소스의 위치는 나는 mac 환경이고 new_version 버전은 0.2.3을 사용하였기 때문에

/Users/${userId}/.pub-cache/hosted/pub.dartlang.org/new_version-0.2.3/lib/new_version.dart

여기에 저장되어 있었다.

 

 

 

 

 

반응형
:
Posted by 리샤씨
2021. 11. 11. 11:58

webview_flutter로 alert/confirm 띄우기 (iOS) 개발/Flutter2021. 11. 11. 11:58

반응형

이전에도 비슷한 주제로  글을 작성한 적이 있었는데 이번엔 해결법이 약간 다르다.

(이전 글 : https://risha-lee.tistory.com/40)

 

이번에는 상황이 Confirm 종류도 많았고,

그냥 웹뷰를 보여주는 앱이었으며
웹은 이미 작성된지 오래된 상태였고

Confirm 이후 분기 처리도 꽤 복잡한 편이어서 꼭 웹의 Confirm 기능을 써야만 했다.

 

일단 Android는 webview_flutter를 2.0.13으로 버전 업하니 alert가 기본으로 작동하게 되었는데 

iOS에서는 여전히 alert/confirm이 안뜨는 문제가 있었다.

찾아보니 역시나 webview_flutter 자체 지원은 안되고 파일 업로드 때 처럼 직접 소스 코드를 수정하기로 하였다.

 

1. 먼저 webview_flutter가 저장되는 위치를 찾는다.

나는 mac 환경이고 webview_flutter 버전은 2.0.13을 사용하였기 때문에

/Users/${userId}/.pub-cache/hosted/pub.dartlang.org/webview_flutter-2.0.13

여기에 저장되어 있었다.

 

2. webview_flutter-2.0.13/ios/Classes/FlutterWebView.m 의 맨 하단 @end 이전에

다음을 추가한다.

- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler{
UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"" message:message?:@"" preferredStyle:UIAlertControllerStyleAlert];
  [alertController addAction:([UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
  completionHandler();
  }])];

    UIViewController *_viewController = [UIApplication sharedApplication].keyWindow.rootViewController;
  [_viewController presentViewController:alertController animated:YES completion:nil];
}

- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL))completionHandler{
  //    DLOG(@"msg = %@ frmae = %@",message,frame);
  UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"" message:message?:@"" preferredStyle:UIAlertControllerStyleAlert];
  [alertController addAction:([UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
    completionHandler(NO);
  }])];
  [alertController addAction:([UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
    completionHandler(YES);
  }])];

    UIViewController *_viewController = [UIApplication sharedApplication].keyWindow.rootViewController;
  [_viewController presentViewController:alertController animated:YES completion:nil];
}

 

 

runJavaScriptTextInputPanelWithPrompt라고 prompt 처리하는 것으로 보이는 것도 있긴한데

내가 작업하는 웹뷰에서 사용하지 않아서 확인하진 못하였으나 비슷한 방식으로 변환하면 다음과 같다.

- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable))completionHandler{
  UIAlertController *alertController = [UIAlertController alertControllerWithTitle:prompt message:@"" preferredStyle:UIAlertControllerStyleAlert];
  [alertController addTextFieldWithConfigurationHandler:^(UITextField * _Nonnull textField) {
    textField.text = defaultText;
  }];
  [alertController addAction:([UIAlertAction actionWithTitle:@"" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
    completionHandler(alertController.textFields[0].text?:@"");
  }])];
  UIViewController *_viewController = [UIApplication sharedApplication].keyWindow.rootViewController;
  [_viewController presentViewController:alertController animated:YES completion:nil];
}

 

 

 

참고 출처 : https://codertw.com/%E7%A8%8B%E5%BC%8F%E8%AA%9E%E8%A8%80/695084/

https://stackoverflow.com/questions/6131205/how-to-find-topmost-view-controller-on-ios

반응형
:
Posted by 리샤씨
2021. 11. 9. 12:52

webview_flutter로 file upload하기 (Android) 개발/Flutter2021. 11. 9. 12:52

반응형

최근 flutter로 웹뷰에서 파일 업로드해야 하는 일이 생겼는데 

Android webview_flutter(https://pub.dev/packages/webview_flutter)에서

그냥 input file 은 작동하지 않는 것을 확인하였다.

 

검색해보니 flutter_webview_plugin(https://pub.dev/packages/flutter_webview_plugin)를

이용하면 별도 작업없이 바로 되는 것 같아 보였다.

하지만 flutter_webview_plugin로는 이미 개발해둔 javasrcipt와 flutter의 통신

페이지 네비게이션 등 처리가 될 것 같지 않아보여서 다른 방법을 더 찾아보았다.

 

결국 직접적으로 webview_flutter의 소스를 건드는 방법을 알게되어 그 소스를 공유하도록 하겠다.

 

1. 먼저 webview_flutter가 저장되는 위치를 찾는다.

나는 mac 환경이고 webview_flutter 버전은 2.0.13을 사용하였기 때문에

/Users/${userId}/.pub-cache/hosted/pub.dartlang.org/webview_flutter-2.0.13

여기에 저장되어 있었다.

 

2. webview_flutter-2.0.13/android/src/main/AndroidManifest.xml를 아래 소스로 수정한다.

<manifest xmlns:android="http://schemas.android.com/apk/res/android" 
    package="io.flutter.plugins.webviewflutter">
<application>
 <provider
     android:name="io.flutter.plugins.webviewflutter.GenericFileProvider"
     android:authorities="${applicationId}.generic.provider"
     android:exported="false"
     android:grantUriPermissions="true"
     >
     <meta-data
         android:name="android.support.FILE_PROVIDER_PATHS"
         android:resource="@xml/provider_paths"
         />
 </provider>
 <activity android:name="io.flutter.plugins.webviewflutter.FileChooserActivity">
 </activity>
 <activity android:name="io.flutter.plugins.webviewflutter.RequestCameraPermissionActivity">
 </activity>

 </application>
 <queries>
     <package android:name="com.google.android.apps.photos" />
     <package android:name="com.google.android.apps.docs" />
     <package android:name="com.google.android.documentsui" />
 </queries>
</manifest>

 

3. webview_flutter-2.0.13/android/src/main/res/values/strings.xml를 추가한다.

경로에 폴더가 없다면 폴더도 추가한다.

<resources>
    <string name="webview_file_chooser_title">Choose a file</string>
    <string name="webview_image_chooser_title">Choose an image</string>
    <string name="webview_video_chooser_title">Choose a video</string>
</resources>

 

4. webview_flutter-2.0.13/android/src/main/res/xml/provider_paths.xml를 추가한다.

경로에 폴더가 없다면 폴더도 추가한다.

 <paths xmlns:android="http://schemas.android.com/apk/res/android">
     <external-files-path name="safetyapp_images" path="images" />
 </paths>

 

5. webview_flutter-2.0.13/android/src/main/java/io/flutter/plugins/webviewflutter/Constants.java를 추가합니다.

package io.flutter.plugins.webviewflutter;

public class Constants {
    static final String ACTION_REQUEST_CAMERA_PERMISSION_FINISHED =
            "action_request_camera_permission_denied";
    static final String ACTION_FILE_CHOOSER_FINISHED = "action_file_chooser_completed";

    static final String EXTRA_TITLE = "extra_title";
    static final String EXTRA_ACCEPT_TYPES = "extra_types";
    static final String EXTRA_SHOW_VIDEO_OPTION = "extra_show_video_option";
    static final String EXTRA_SHOW_IMAGE_OPTION = "extra_show_image_option";
    static final String EXTRA_FILE_URIS = "extra_file_uris";
    static final String EXTRA_ALLOW_MULTIPLE_FILES = "extra_allow_multiple_files";


    static final String WEBVIEW_STORAGE_DIRECTORY = "images";
}

 

6. webview_flutter-2.0.13/android/src/main/java/io/flutter/plugins/webviewflutter/FileChooserActivity.java를 추가한다.

package io.flutter.plugins.webviewflutter;

import static io.flutter.plugins.webviewflutter.Constants.ACTION_FILE_CHOOSER_FINISHED;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_ACCEPT_TYPES;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_ALLOW_MULTIPLE_FILES;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_FILE_URIS;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_SHOW_IMAGE_OPTION;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_SHOW_VIDEO_OPTION;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_TITLE;
import static io.flutter.plugins.webviewflutter.Constants.WEBVIEW_STORAGE_DIRECTORY;

import android.app.Activity;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.provider.MediaStore;
import android.provider.OpenableColumns;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.core.content.FileProvider;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;

public class FileChooserActivity extends Activity {

private static final int FILE_CHOOSER_REQUEST_CODE = 12322;
private static final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd_HHmmss");

// List of Uris that point to files where there MIGHT be the output of the capture. At most one of these can be valid
private final ArrayList<Uri> potentialCaptureOutputUris = new ArrayList<>();

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    showFileChooser(
            getIntent().getBooleanExtra(EXTRA_SHOW_IMAGE_OPTION, false),
            getIntent().getBooleanExtra(EXTRA_SHOW_VIDEO_OPTION, false));
}

private void showFileChooser(boolean showImageIntent, boolean showVideoIntent) {
    Intent getContentIntent = createGetContentIntent();
    Intent captureImageIntent =
            showImageIntent ? createCaptureIntent(MediaStore.ACTION_IMAGE_CAPTURE, "jpg") : null;
    Intent captureVideoIntent =
            showVideoIntent ? createCaptureIntent(MediaStore.ACTION_VIDEO_CAPTURE, "mp4") : null;

    if (getContentIntent == null && captureImageIntent == null && captureVideoIntent == null) {
        // cannot open anything: cancel file chooser
        sendBroadcast(new Intent(ACTION_FILE_CHOOSER_FINISHED));
        finish();
    } else {
        ArrayList<Intent> intentList = new ArrayList<>();

        if (getContentIntent != null) {
            intentList.add(getContentIntent);
        }

        if (captureImageIntent != null) {
            intentList.add(captureImageIntent);
        }
        if (captureVideoIntent != null) {
            intentList.add(captureVideoIntent);
        }

        Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER);
        chooserIntent.putExtra(Intent.EXTRA_TITLE, getIntent().getStringExtra(EXTRA_TITLE));

        chooserIntent.putExtra(Intent.EXTRA_INTENT, intentList.get(0));
        intentList.remove(0);
        if (intentList.size() > 0) {
            chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.toArray(new Intent[0]));
        }

        startActivityForResult(chooserIntent, FILE_CHOOSER_REQUEST_CODE);
    }
}

private Intent createGetContentIntent() {
    Intent filesIntent = new Intent(Intent.ACTION_GET_CONTENT);

    if (getIntent().getBooleanExtra(EXTRA_ALLOW_MULTIPLE_FILES, false)) {
        filesIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
    }

    String[] acceptTypes = getIntent().getStringArrayExtra(EXTRA_ACCEPT_TYPES);

    if (acceptTypes.length == 0 || (acceptTypes.length == 1 && acceptTypes[0].length() == 0)) {
        // empty array or only 1 empty string? -> accept all types
        filesIntent.setType("*/*");
    } else if (acceptTypes.length == 1) {
        filesIntent.setType(acceptTypes[0]);
    } else {
        // acceptTypes.length > 1
        filesIntent.setType("*/*");
        filesIntent.putExtra(Intent.EXTRA_MIME_TYPES, acceptTypes);
    }

    return (filesIntent.resolveActivity(getPackageManager()) != null) ? filesIntent : null;
}

private Intent createCaptureIntent(String type, String fileFormat) {
    Intent captureIntent = new Intent(type);
    if (captureIntent.resolveActivity(getPackageManager()) == null) {
        return null;
    }

    // Create the File where the output should go
    Uri captureOutputUri = getTempUri(fileFormat);
    potentialCaptureOutputUris.add(captureOutputUri);

    captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, captureOutputUri);

    return captureIntent;
}
private File getStorageDirectory() {
    File imageDirectory = new File(this.getExternalFilesDir(null), WEBVIEW_STORAGE_DIRECTORY);
    if (!imageDirectory.isDirectory()) {
        imageDirectory.mkdir();
    }
    return imageDirectory;
}


private Uri getTempUri(String format) {
    String fileName = "CAPTURE-" + simpleDateFormat.format(new Date()) + "." + format;
    File file = new File(getStorageDirectory(), fileName);
    return FileProvider.getUriForFile(
            this, getApplicationContext().getPackageName() + ".generic.provider", file);
}

private String getFileNameFromUri(Uri uri) {
    Cursor returnCursor = getContentResolver().query(uri, null, null, null, null);
    assert returnCursor != null;
    int nameIndex = returnCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
    returnCursor.moveToFirst();
    String name = returnCursor.getString(nameIndex);
    returnCursor.close();
    return name;
}

private Uri copyToLocalUri(Uri uri) {
    File destination = new File(getStorageDirectory(), getFileNameFromUri(uri));

    try (InputStream in = getContentResolver().openInputStream(uri);
         OutputStream out = new FileOutputStream(destination)) {
        byte[] buffer = new byte[1024];
        int len;
        while ((len = in.read(buffer)) != -1) {
            out.write(buffer, 0, len);
        }
        return FileProvider.getUriForFile(
                this, getApplicationContext().getPackageName() + ".generic.provider", destination);
    } catch (IOException e) {
        Log.e("WEBVIEW", "Unable to copy selected image", e);
        e.printStackTrace();
        return null;
    }
}

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (requestCode == FILE_CHOOSER_REQUEST_CODE) {
        Intent fileChooserFinishedIntent = new Intent(ACTION_FILE_CHOOSER_FINISHED);
        if (resultCode == Activity.RESULT_OK) {
            if (data != null && (data.getDataString() != null || data.getClipData() != null)) {
                if (data.getDataString() != null) {
                    // single result from file browser OR video from camera
                    Uri localUri = copyToLocalUri(data.getData());
                    if (localUri != null) {
                        fileChooserFinishedIntent.putExtra(
                                EXTRA_FILE_URIS, new String[] {localUri.toString()});
                    }
                } else if (data.getClipData() != null) {
                    // multiple results from file browser
                    int uriCount = data.getClipData().getItemCount();
                    String[] uriStrings = new String[uriCount];

                    for (int i = 0; i < uriCount; i++) {
                        Uri localUri = copyToLocalUri(data.getClipData().getItemAt(i).getUri());
                        if (localUri != null) {
                            uriStrings[i] = localUri.toString();
                        }
                    }
                    fileChooserFinishedIntent.putExtra(EXTRA_FILE_URIS, uriStrings);
                }
            } else {
                // image result from camera (videos from the camera are handled above, but this if-branch could handle them too if this varies from device to device)
                for (Uri captureOutputUri : potentialCaptureOutputUris) {
                    try {
                        // just opening an input stream (and closing immediately) to test if the Uri points to a valid file
                        // if it's not a real file, the below catch-clause gets executed and we continue with the next Uri in the loop.
                        getContentResolver().openInputStream(captureOutputUri).close();
                        fileChooserFinishedIntent.putExtra(
                                EXTRA_FILE_URIS, new String[] {captureOutputUri.toString()});
                        // leave the loop, as only one of the potentialCaptureOutputUris is valid and we just found it
                        break;
                    } catch (IOException ignored) {
                    }
                }
            }
        }
        sendBroadcast(fileChooserFinishedIntent);
        finish();
    } else {
        super.onActivityResult(requestCode, resultCode, data);
    }
   }
 }

 

7. webview_flutter-2.0.13/android/src/main/java/io/flutter/plugins/webviewflutter/FileChooserLauncher.java를 추가한다.

package io.flutter.plugins.webviewflutter;

import static io.flutter.plugins.webviewflutter.Constants.ACTION_FILE_CHOOSER_FINISHED;
import static io.flutter.plugins.webviewflutter.Constants.ACTION_REQUEST_CAMERA_PERMISSION_FINISHED;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_ACCEPT_TYPES;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_ALLOW_MULTIPLE_FILES;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_FILE_URIS;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_SHOW_IMAGE_OPTION;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_SHOW_VIDEO_OPTION;
import static io.flutter.plugins.webviewflutter.Constants.EXTRA_TITLE;

import android.Manifest;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.webkit.ValueCallback;
import androidx.core.content.ContextCompat;

public class FileChooserLauncher extends BroadcastReceiver {

private Context context;
private String title;
private boolean allowMultipleFiles;
private boolean videoAcceptable;
private boolean imageAcceptable;
private ValueCallback<Uri[]> filePathCallback;
private String[] acceptTypes;

public FileChooserLauncher(
        Context context,
        boolean allowMultipleFiles,
        ValueCallback<Uri[]> filePathCallback,
        String[] acceptTypes) {
    this.context = context;
    this.allowMultipleFiles = allowMultipleFiles;
    this.filePathCallback = filePathCallback;
    this.acceptTypes = acceptTypes;

    if (acceptTypes.length == 0 || (acceptTypes.length == 1 && acceptTypes[0].length() == 0)) {
        // acceptTypes empty -> accept anything
        imageAcceptable = true;
        videoAcceptable = true;
    } else {
        for (String acceptType : acceptTypes) {
            if (acceptType.startsWith("image/")) {
                imageAcceptable = true;
            } else if (acceptType.startsWith("video/")) {
                videoAcceptable = true;
            }
        }
    }

    if (imageAcceptable && !videoAcceptable) {
        title = context.getResources().getString(R.string.webview_image_chooser_title);
    } else if (videoAcceptable && !imageAcceptable) {
        title = context.getResources().getString(R.string.webview_video_chooser_title);
    } else {
        title = context.getResources().getString(R.string.webview_file_chooser_title);
    }
}

private boolean canCameraProduceAcceptableType() {
    return imageAcceptable || videoAcceptable;
}

private boolean hasCameraPermission() {
    return ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
            == PackageManager.PERMISSION_GRANTED;
}

public void start() {
    if (!canCameraProduceAcceptableType() || hasCameraPermission()) {
        showFileChooser();
    } else {
        IntentFilter intentFilter = new IntentFilter();
        intentFilter.addAction(ACTION_REQUEST_CAMERA_PERMISSION_FINISHED);
        context.registerReceiver(this, intentFilter);

        Intent intent = new Intent(context, RequestCameraPermissionActivity.class);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        context.startActivity(intent);
    }
}

private void showFileChooser() {
    IntentFilter intentFilter = new IntentFilter(ACTION_FILE_CHOOSER_FINISHED);
    context.registerReceiver(this, intentFilter);

    Intent intent = new Intent(context, FileChooserActivity.class);
    intent.putExtra(EXTRA_TITLE, title);
    intent.putExtra(EXTRA_ACCEPT_TYPES, acceptTypes);
    intent.putExtra(EXTRA_SHOW_IMAGE_OPTION, imageAcceptable && hasCameraPermission());
    intent.putExtra(EXTRA_SHOW_VIDEO_OPTION, videoAcceptable && hasCameraPermission());
    intent.putExtra(EXTRA_ALLOW_MULTIPLE_FILES, allowMultipleFiles);
    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    context.startActivity(intent);
}

@Override
public void onReceive(Context context, Intent intent) {
    if (intent.getAction().equals(ACTION_REQUEST_CAMERA_PERMISSION_FINISHED)) {
        context.unregisterReceiver(this);
        showFileChooser();
    } else if (intent.getAction().equals(ACTION_FILE_CHOOSER_FINISHED)) {
        String[] uriStrings = intent.getStringArrayExtra(EXTRA_FILE_URIS);
        Uri[] result = null;

        if (uriStrings != null) {
            int uriStringCount = uriStrings.length;
            result = new Uri[uriStringCount];

            for (int i = 0; i < uriStringCount; i++) {
                result[i] = Uri.parse(uriStrings[i]);
            }
        }

        filePathCallback.onReceiveValue(result);
        context.unregisterReceiver(this);
        filePathCallback = null;
    }
  }
 }

 

8. webview_flutter-2.0.13/android/src/main/java/io/flutter/plugins/webviewflutter/FlutterWebView.java

class FlutterWebChromeClient안에 메소드를 추가한다.

import android.net.Uri;
import android.webkit.ValueCallback;


    @Override
    public boolean onShowFileChooser(
            WebView webView,
            ValueCallback<Uri[]> filePathCallback,
            FileChooserParams fileChooserParams) {
      // info as of 2021-03-08:
      // don't use fileChooserParams.getTitle() as it is (always? on                Mi 9T Pro Android 10 at least) null
      // don't use fileChooserParams.isCaptureEnabled() as it is (always? on Mi 9T Pro Android 10 at least) false, even when the file upload allows images or any file
      final Context context = webView.getContext();
      final boolean allowMultipleFiles = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
              && fileChooserParams.getMode() == FileChooserParams.MODE_OPEN_MULTIPLE;
      final String[] acceptTypes = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
              ? fileChooserParams.getAcceptTypes() : new String[0];
      new FileChooserLauncher(context, allowMultipleFiles, filePathCallback, acceptTypes)
              .start();
      return true;
    }

 

9. webview_flutter-2.0.13/android/src/main/java/io/flutter/plugins/webviewflutter/RequestCameraPermissionActivity.java를 추가합니다.

package io.flutter.plugins.webviewflutter;

import android.Manifest; 
import android.app.Activity; 
import android.content.Intent; 
import android.os.Bundle; 
import androidx.annotation.NonNull; 
import androidx.annotation.Nullable; 
import androidx.core.app.ActivityCompat;
import static io.flutter.plugins.webviewflutter.Constants.ACTION_REQUEST_CAMERA_PERMISSION_FINISHED;

public class RequestCameraPermissionActivity extends Activity {

    private static final int CAMERA_PERMISSION_REQUEST_CODE = 12321;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        ActivityCompat.requestPermissions(
                this, new String[] {Manifest.permission.CAMERA}, CAMERA_PERMISSION_REQUEST_CODE);
    }

    @Override
    public void onRequestPermissionsResult(
            int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) {
            sendBroadcast(new Intent(ACTION_REQUEST_CAMERA_PERMISSION_FINISHED));
            finish();
        } else {
            super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        }
    }
}

 

10. webview_flutter-2.0.13/android/src/main/java/io/flutter/plugins/webviewflutter/GenericFileProvider.java를 추가한다.

package io.flutter.plugins.webviewflutter;

import androidx.core.content.FileProvider;

public class GenericFileProvider extends FileProvider {

    public static final String PROVIDER_NAME = "io.flutter.plugins.webviewflutter.GenericFileProvider";

}

 

11. input file 클릭시 카메라로 직접 사진을 찍어 파일을 올리게 하려면 원래 flutter project에서 android/app/src/main/AndroidManifest.xml 에 <uses-permission android:name="android.permission.CAMERA"/> 권한을 추가한다. (선택)

 

카메라 권한 확인 팝업 / 파일 선택 팝업

 

 

 

출처 :

https://stackoverflow.com/questions/60289932/is-there-a-way-of-making-a-flutter-webview-use-android-camera-for-file-upload-h
https://github.com/pubnative/easy-files-android/blob/master/app/src/main/java/net/easynaps/easyfiles/utils/GenericFileProvider.java

https://m.blog.naver.com/websearch/221738662288

 

 

 

 

반응형
:
Posted by 리샤씨


반응형
반응형