Merge branch 'main' into add_location_screen

This commit is contained in:
ashilkn 2023-03-27 10:10:38 +05:30
commit 8283612f0e
42 changed files with 1963 additions and 555 deletions

33
.github/workflows/crowdin.yml vendored Normal file
View file

@ -0,0 +1,33 @@
name: Sync crowdin translation
on:
workflow_dispatch:
push:
paths:
- 'lib/l10n/app_en.arb'
branches: [ main ]
jobs:
synchronize-with-crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: crowdin action
uses: crowdin/github-action@v1
with:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: l10n_translations
create_pull_request: true
skip_untranslated_strings: true
pull_request_title: 'New Translations'
pull_request_body: 'New translations via [Crowdin GH Action](https://github.com/crowdin/github-action)'
pull_request_base_branch_name: 'main'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,30 @@
waterfall
snow
landscape
underwater
architecture
sunset / sunrise
blue sky
cloudy sky
greenery
autumn leaves
potrait
flower
night shot
stage concert
fireworks
candle light
neon lights
indoor
backlight
text documents
qr images
group potrait
computer screens
kids
dog
cat
macro
food
beach
mountain

Binary file not shown.

6
crowdin.yml Normal file
View file

@ -0,0 +1,6 @@
project_id_env: CROWDIN_PROJECT_ID
api_token_env: CROWDIN_PERSONAL_TOKEN
files:
- source: /lib/l10n/app_en.arb
translation: /lib/l10n/app_%two_letters_code%.arb

1
lib/l10n/app_de.arb Normal file
View file

@ -0,0 +1 @@
{}

6
lib/l10n/app_fr.arb Normal file
View file

@ -0,0 +1,6 @@
{
"sign_up": "inscription",
"@sign_up": {
"description": "Text on the sign up button used during registration"
}
}

1
lib/l10n/app_it.arb Normal file
View file

@ -0,0 +1 @@
{}

1
lib/l10n/app_nl.arb Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -131,7 +131,8 @@ class BonusDetails {
factory BonusDetails.fromJson(Map<String, dynamic> json) => BonusDetails(
referralStats: List<ReferralStat>.from(
json["referralStats"].map((x) => ReferralStat.fromJson(x))),
json["referralStats"].map((x) => ReferralStat.fromJson(x)),
),
bonuses:
List<Bonus>.from(json["bonuses"].map((x) => Bonus.fromJson(x))),
refCount: json["refCount"],

View file

@ -11,6 +11,10 @@ class DeviceCollection {
int? collectionID;
File? thumbnail;
bool hasCollectionID() {
return collectionID != null && collectionID! != -1;
}
DeviceCollection(
this.id,
this.name, {

View file

@ -137,7 +137,10 @@ class FavoritesService {
}
Future<void> updateFavorites(
BuildContext context, List<File> files, bool favFlag) async {
BuildContext context,
List<File> files,
bool favFlag,
) async {
final int currentUserID = Configuration.instance.getUserID()!;
if (files.any((f) => f.uploadedFileID == null)) {
throw AssertionError("Can only favorite uploaded items");

View file

@ -4,18 +4,20 @@ import "dart:typed_data";
import "package:logging/logging.dart";
import "package:photos/services/object_detection/models/predictions.dart";
import 'package:photos/services/object_detection/models/recognition.dart';
import "package:photos/services/object_detection/tflite/classifier.dart";
import 'package:photos/services/object_detection/tflite/cocossd_classifier.dart';
import "package:photos/services/object_detection/tflite/mobilenet_classifier.dart";
import "package:photos/services/object_detection/tflite/scene_classifier.dart";
import "package:photos/services/object_detection/utils/isolate_utils.dart";
class ObjectDetectionService {
static const scoreThreshold = 0.6;
static const scoreThreshold = 0.5;
final _logger = Logger("ObjectDetectionService");
/// Instance of [ObjectClassifier]
late ObjectClassifier _classifier;
late CocoSSDClassifier _objectClassifier;
late MobileNetClassifier _mobileNetClassifier;
late SceneClassifier _sceneClassifier;
/// Instance of [IsolateUtils]
late IsolateUtils _isolateUtils;
ObjectDetectionService._privateConstructor();
@ -23,7 +25,9 @@ class ObjectDetectionService {
Future<void> init() async {
_isolateUtils = IsolateUtils();
await _isolateUtils.start();
_classifier = ObjectClassifier();
_objectClassifier = CocoSSDClassifier();
_mobileNetClassifier = MobileNetClassifier();
_sceneClassifier = SceneClassifier();
}
static ObjectDetectionService instance =
@ -31,18 +35,10 @@ class ObjectDetectionService {
Future<List<String>> predict(Uint8List bytes) async {
try {
final isolateData = IsolateData(
bytes,
_classifier.interpreter.address,
_classifier.labels,
);
final predictions = await _inference(isolateData);
final Set<String> results = {};
for (final Recognition result in predictions.recognitions) {
if (result.score > scoreThreshold) {
results.add(result.label);
}
}
final results = <String>{};
results.addAll(await _getObjects(bytes));
results.addAll(await _getMobileNetResults(bytes));
results.addAll(await _getSceneResults(bytes));
return results.toList();
} catch (e, s) {
_logger.severe(e, s);
@ -50,6 +46,54 @@ class ObjectDetectionService {
}
}
Future<List<String>> _getObjects(Uint8List bytes) async {
final isolateData = IsolateData(
bytes,
_objectClassifier.interpreter.address,
_objectClassifier.labels,
ClassifierType.cocossd,
);
return _getPredictions(isolateData);
}
Future<List<String>> _getMobileNetResults(Uint8List bytes) async {
final isolateData = IsolateData(
bytes,
_mobileNetClassifier.interpreter.address,
_mobileNetClassifier.labels,
ClassifierType.mobilenet,
);
return _getPredictions(isolateData);
}
Future<List<String>> _getSceneResults(Uint8List bytes) async {
final isolateData = IsolateData(
bytes,
_sceneClassifier.interpreter.address,
_sceneClassifier.labels,
ClassifierType.scenes,
);
return _getPredictions(isolateData);
}
Future<List<String>> _getPredictions(IsolateData isolateData) async {
final predictions = await _inference(isolateData);
final Set<String> results = {};
for (final Recognition result in predictions.recognitions) {
if (result.score > scoreThreshold) {
results.add(result.label);
}
}
_logger.info(
"Time taken for " +
isolateData.type.toString() +
": " +
predictions.stats.totalElapsedTime.toString() +
"ms",
);
return results.toList();
}
/// Runs inference in another isolate
Future<Predictions> _inference(IsolateData isolateData) async {
final responsePort = ReceivePort();

View file

@ -1,16 +1,25 @@
import 'dart:math';
import "dart:math";
import 'package:image/image.dart' as imageLib;
import 'package:image/image.dart' as image_lib;
import "package:logging/logging.dart";
import 'package:photos/services/object_detection/models/predictions.dart';
import 'package:photos/services/object_detection/models/recognition.dart';
import "package:photos/services/object_detection/models/stats.dart";
import "package:photos/services/object_detection/models/predictions.dart";
import "package:tflite_flutter/tflite_flutter.dart";
import "package:tflite_flutter_helper/tflite_flutter_helper.dart";
/// Classifier
class ObjectClassifier {
final _logger = Logger("Classifier");
abstract class Classifier {
// Path to the model
String get modelPath;
// Path to the labels
String get labelPath;
// Input size expected by the model (for eg. width = height = 224)
int get inputSize;
// Logger implementation for the specific classifier
Logger get logger;
Predictions? predict(image_lib.Image image);
/// Instance of Interpreter
late Interpreter _interpreter;
@ -18,44 +27,30 @@ class ObjectClassifier {
/// Labels file loaded as list
late List<String> _labels;
/// Input size of image (height = width = 300)
static const int inputSize = 300;
/// Result score threshold
static const double threshold = 0.5;
static const String modelFileName = "detect.tflite";
static const String labelFileName = "labelmap.txt";
/// [ImageProcessor] used to pre-process the image
ImageProcessor? imageProcessor;
/// Padding the image to transform into square
late int padSize;
/// Shapes of output tensors
late List<List<int>> _outputShapes;
/// Types of output tensors
late List<TfLiteType> _outputTypes;
/// Number of results to show
static const int numResults = 10;
/// Gets the interpreter instance
Interpreter get interpreter => _interpreter;
ObjectClassifier({
Interpreter? interpreter,
List<String>? labels,
}) {
loadModel(interpreter);
loadLabels(labels);
}
/// Gets the loaded labels
List<String> get labels => _labels;
/// Gets the output shapes
List<List<int>> get outputShapes => _outputShapes;
/// Gets the output types
List<TfLiteType> get outputTypes => _outputTypes;
/// Loads interpreter from asset
void loadModel(Interpreter? interpreter) async {
try {
_interpreter = interpreter ??
await Interpreter.fromAsset(
"models/" + modelFileName,
modelPath,
options: InterpreterOptions()..threads = 4,
);
final outputTensors = _interpreter.getOutputTensors();
@ -65,115 +60,30 @@ class ObjectClassifier {
_outputShapes.add(tensor.shape);
_outputTypes.add(tensor.type);
});
_logger.info("Interpreter initialized");
logger.info("Interpreter initialized");
} catch (e, s) {
_logger.severe("Error while creating interpreter", e, s);
logger.severe("Error while creating interpreter", e, s);
}
}
/// Loads labels from assets
void loadLabels(List<String>? labels) async {
try {
_labels =
labels ?? await FileUtil.loadLabels("assets/models/" + labelFileName);
_logger.info("Labels initialized");
_labels = labels ?? await FileUtil.loadLabels(labelPath);
logger.info("Labels initialized");
} catch (e, s) {
_logger.severe("Error while loading labels", e, s);
logger.severe("Error while loading labels", e, s);
}
}
/// Pre-process the image
TensorImage _getProcessedImage(TensorImage inputImage) {
padSize = max(inputImage.height, inputImage.width);
imageProcessor ??= ImageProcessorBuilder()
TensorImage getProcessedImage(TensorImage inputImage) {
final padSize = max(inputImage.height, inputImage.width);
final imageProcessor = ImageProcessorBuilder()
.add(ResizeWithCropOrPadOp(padSize, padSize))
.add(ResizeOp(inputSize, inputSize, ResizeMethod.BILINEAR))
.build();
inputImage = imageProcessor!.process(inputImage);
inputImage = imageProcessor.process(inputImage);
return inputImage;
}
/// Runs object detection on the input image
Predictions? predict(imageLib.Image image) {
final predictStartTime = DateTime.now().millisecondsSinceEpoch;
final preProcessStart = DateTime.now().millisecondsSinceEpoch;
// Create TensorImage from image
TensorImage inputImage = TensorImage.fromImage(image);
// Pre-process TensorImage
inputImage = _getProcessedImage(inputImage);
final preProcessElapsedTime =
DateTime.now().millisecondsSinceEpoch - preProcessStart;
// TensorBuffers for output tensors
final outputLocations = TensorBufferFloat(_outputShapes[0]);
final outputClasses = TensorBufferFloat(_outputShapes[1]);
final outputScores = TensorBufferFloat(_outputShapes[2]);
final numLocations = TensorBufferFloat(_outputShapes[3]);
// Inputs object for runForMultipleInputs
// Use [TensorImage.buffer] or [TensorBuffer.buffer] to pass by reference
final inputs = [inputImage.buffer];
// Outputs map
final outputs = {
0: outputLocations.buffer,
1: outputClasses.buffer,
2: outputScores.buffer,
3: numLocations.buffer,
};
final inferenceTimeStart = DateTime.now().millisecondsSinceEpoch;
// run inference
_interpreter.runForMultipleInputs(inputs, outputs);
final inferenceTimeElapsed =
DateTime.now().millisecondsSinceEpoch - inferenceTimeStart;
// Maximum number of results to show
final resultsCount = min(numResults, numLocations.getIntValue(0));
// Using labelOffset = 1 as ??? at index 0
const labelOffset = 1;
final recognitions = <Recognition>[];
for (int i = 0; i < resultsCount; i++) {
// Prediction score
final score = outputScores.getDoubleValue(i);
// Label string
final labelIndex = outputClasses.getIntValue(i) + labelOffset;
final label = _labels.elementAt(labelIndex);
if (score > threshold) {
recognitions.add(
Recognition(i, label, score),
);
}
}
final predictElapsedTime =
DateTime.now().millisecondsSinceEpoch - predictStartTime;
_logger.info(recognitions);
return Predictions(
recognitions,
Stats(
predictElapsedTime,
predictElapsedTime,
inferenceTimeElapsed,
preProcessElapsedTime,
),
);
}
/// Gets the interpreter instance
Interpreter get interpreter => _interpreter;
/// Gets the loaded labels
List<String> get labels => _labels;
}

View file

@ -0,0 +1,115 @@
import 'dart:math';
import 'package:image/image.dart' as image_lib;
import "package:logging/logging.dart";
import 'package:photos/services/object_detection/models/predictions.dart';
import 'package:photos/services/object_detection/models/recognition.dart';
import "package:photos/services/object_detection/models/stats.dart";
import "package:photos/services/object_detection/tflite/classifier.dart";
import "package:tflite_flutter/tflite_flutter.dart";
import "package:tflite_flutter_helper/tflite_flutter_helper.dart";
/// Classifier
class CocoSSDClassifier extends Classifier {
static final _logger = Logger("CocoSSDClassifier");
static const double threshold = 0.5;
@override
String get modelPath => "models/cocossd/model.tflite";
@override
String get labelPath => "assets/models/cocossd/labels.txt";
@override
int get inputSize => 300;
@override
Logger get logger => _logger;
static const int numResults = 10;
CocoSSDClassifier({
Interpreter? interpreter,
List<String>? labels,
}) {
loadModel(interpreter);
loadLabels(labels);
}
@override
Predictions? predict(image_lib.Image image) {
final predictStartTime = DateTime.now().millisecondsSinceEpoch;
final preProcessStart = DateTime.now().millisecondsSinceEpoch;
// Create TensorImage from image
TensorImage inputImage = TensorImage.fromImage(image);
// Pre-process TensorImage
inputImage = getProcessedImage(inputImage);
final preProcessElapsedTime =
DateTime.now().millisecondsSinceEpoch - preProcessStart;
// TensorBuffers for output tensors
final outputLocations = TensorBufferFloat(outputShapes[0]);
final outputClasses = TensorBufferFloat(outputShapes[1]);
final outputScores = TensorBufferFloat(outputShapes[2]);
final numLocations = TensorBufferFloat(outputShapes[3]);
// Inputs object for runForMultipleInputs
// Use [TensorImage.buffer] or [TensorBuffer.buffer] to pass by reference
final inputs = [inputImage.buffer];
// Outputs map
final outputs = {
0: outputLocations.buffer,
1: outputClasses.buffer,
2: outputScores.buffer,
3: numLocations.buffer,
};
final inferenceTimeStart = DateTime.now().millisecondsSinceEpoch;
// run inference
interpreter.runForMultipleInputs(inputs, outputs);
final inferenceTimeElapsed =
DateTime.now().millisecondsSinceEpoch - inferenceTimeStart;
// Maximum number of results to show
final resultsCount = min(numResults, numLocations.getIntValue(0));
// Using labelOffset = 1 as ??? at index 0
const labelOffset = 1;
final recognitions = <Recognition>[];
for (int i = 0; i < resultsCount; i++) {
// Prediction score
final score = outputScores.getDoubleValue(i);
// Label string
final labelIndex = outputClasses.getIntValue(i) + labelOffset;
final label = labels.elementAt(labelIndex);
if (score > threshold) {
recognitions.add(
Recognition(i, label, score),
);
}
}
final predictElapsedTime =
DateTime.now().millisecondsSinceEpoch - predictStartTime;
return Predictions(
recognitions,
Stats(
predictElapsedTime,
predictElapsedTime,
inferenceTimeElapsed,
preProcessElapsedTime,
),
);
}
}

View file

@ -0,0 +1,84 @@
import 'package:image/image.dart' as image_lib;
import "package:logging/logging.dart";
import 'package:photos/services/object_detection/models/predictions.dart';
import 'package:photos/services/object_detection/models/recognition.dart';
import "package:photos/services/object_detection/models/stats.dart";
import "package:photos/services/object_detection/tflite/classifier.dart";
import "package:tflite_flutter/tflite_flutter.dart";
import "package:tflite_flutter_helper/tflite_flutter_helper.dart";
// Source: https://tfhub.dev/tensorflow/lite-model/mobilenet_v1_1.0_224/1/default/1
class MobileNetClassifier extends Classifier {
static final _logger = Logger("MobileNetClassifier");
static const double threshold = 0.5;
@override
String get modelPath => "models/mobilenet/mobilenet_v1_1.0_224_quant.tflite";
@override
String get labelPath =>
"assets/models/mobilenet/labels_mobilenet_quant_v1_224.txt";
@override
int get inputSize => 224;
@override
Logger get logger => _logger;
MobileNetClassifier({
Interpreter? interpreter,
List<String>? labels,
}) {
loadModel(interpreter);
loadLabels(labels);
}
@override
Predictions? predict(image_lib.Image image) {
final predictStartTime = DateTime.now().millisecondsSinceEpoch;
final preProcessStart = DateTime.now().millisecondsSinceEpoch;
// Create TensorImage from image
TensorImage inputImage = TensorImage.fromImage(image);
// Pre-process TensorImage
inputImage = getProcessedImage(inputImage);
final preProcessElapsedTime =
DateTime.now().millisecondsSinceEpoch - preProcessStart;
// TensorBuffers for output tensors
final output = TensorBufferUint8(outputShapes[0]);
final inferenceTimeStart = DateTime.now().millisecondsSinceEpoch;
// run inference
interpreter.run(inputImage.buffer, output.buffer);
final inferenceTimeElapsed =
DateTime.now().millisecondsSinceEpoch - inferenceTimeStart;
final recognitions = <Recognition>[];
for (int i = 0; i < labels.length; i++) {
final score = output.getDoubleValue(i) / 255;
if (score >= threshold) {
final label = labels.elementAt(i);
recognitions.add(
Recognition(i, label, score),
);
}
}
final predictElapsedTime =
DateTime.now().millisecondsSinceEpoch - predictStartTime;
return Predictions(
recognitions,
Stats(
predictElapsedTime,
predictElapsedTime,
inferenceTimeElapsed,
preProcessElapsedTime,
),
);
}
}

View file

@ -0,0 +1,82 @@
import 'package:image/image.dart' as image_lib;
import "package:logging/logging.dart";
import 'package:photos/services/object_detection/models/predictions.dart';
import 'package:photos/services/object_detection/models/recognition.dart';
import "package:photos/services/object_detection/models/stats.dart";
import "package:photos/services/object_detection/tflite/classifier.dart";
import "package:tflite_flutter/tflite_flutter.dart";
import "package:tflite_flutter_helper/tflite_flutter_helper.dart";
// Source: https://tfhub.dev/sayannath/lite-model/image-scene/1
class SceneClassifier extends Classifier {
static final _logger = Logger("SceneClassifier");
static const double threshold = 0.5;
@override
String get modelPath => "models/scenes/model.tflite";
@override
String get labelPath => "assets/models/scenes/labels.txt";
@override
int get inputSize => 224;
@override
Logger get logger => _logger;
SceneClassifier({
Interpreter? interpreter,
List<String>? labels,
}) {
loadModel(interpreter);
loadLabels(labels);
}
@override
Predictions? predict(image_lib.Image image) {
final predictStartTime = DateTime.now().millisecondsSinceEpoch;
final preProcessStart = DateTime.now().millisecondsSinceEpoch;
// Create TensorImage from image
TensorImage inputImage = TensorImage.fromImage(image);
// Pre-process TensorImage
inputImage = getProcessedImage(inputImage);
final list = inputImage.getTensorBuffer().getDoubleList();
final input = list.reshape([1, inputSize, inputSize, 3]);
final preProcessElapsedTime =
DateTime.now().millisecondsSinceEpoch - preProcessStart;
final output = TensorBufferFloat(outputShapes[0]);
final inferenceTimeStart = DateTime.now().millisecondsSinceEpoch;
interpreter.run(input, output.buffer);
final inferenceTimeElapsed =
DateTime.now().millisecondsSinceEpoch - inferenceTimeStart;
final recognitions = <Recognition>[];
for (int i = 0; i < labels.length; i++) {
final score = output.getDoubleValue(i);
final label = labels.elementAt(i);
if (score >= threshold) {
recognitions.add(
Recognition(i, label, score),
);
}
}
final predictElapsedTime =
DateTime.now().millisecondsSinceEpoch - predictStartTime;
return Predictions(
recognitions,
Stats(
predictElapsedTime,
predictElapsedTime,
inferenceTimeElapsed,
preProcessElapsedTime,
),
);
}
}

View file

@ -3,6 +3,9 @@ import "dart:typed_data";
import 'package:image/image.dart' as imgLib;
import "package:photos/services/object_detection/tflite/classifier.dart";
import 'package:photos/services/object_detection/tflite/cocossd_classifier.dart';
import "package:photos/services/object_detection/tflite/mobilenet_classifier.dart";
import "package:photos/services/object_detection/tflite/scene_classifier.dart";
import 'package:tflite_flutter/tflite_flutter.dart';
/// Manages separate Isolate instance for inference
@ -29,15 +32,32 @@ class IsolateUtils {
sendPort.send(port.sendPort);
await for (final IsolateData isolateData in port) {
final classifier = ObjectClassifier(
interpreter: Interpreter.fromAddress(isolateData.interpreterAddress),
labels: isolateData.labels,
);
final classifier = _getClassifier(isolateData);
final image = imgLib.decodeImage(isolateData.input);
final results = classifier.predict(image!);
isolateData.responsePort.send(results);
}
}
static Classifier _getClassifier(IsolateData isolateData) {
final interpreter = Interpreter.fromAddress(isolateData.interpreterAddress);
if (isolateData.type == ClassifierType.cocossd) {
return CocoSSDClassifier(
interpreter: interpreter,
labels: isolateData.labels,
);
} else if (isolateData.type == ClassifierType.mobilenet) {
return MobileNetClassifier(
interpreter: interpreter,
labels: isolateData.labels,
);
} else {
return SceneClassifier(
interpreter: interpreter,
labels: isolateData.labels,
);
}
}
}
/// Bundles data to pass between Isolate
@ -45,11 +65,19 @@ class IsolateData {
Uint8List input;
int interpreterAddress;
List<String> labels;
ClassifierType type;
late SendPort responsePort;
IsolateData(
this.input,
this.interpreterAddress,
this.labels,
this.type,
);
}
enum ClassifierType {
cocossd,
mobilenet,
scenes,
}

View file

@ -433,23 +433,36 @@ class RemoteSyncService {
}
Future<int?> _getCollectionID(DeviceCollection deviceCollection) async {
if (deviceCollection.collectionID != null) {
if (deviceCollection.hasCollectionID()) {
final collection =
_collectionsService.getCollectionByID(deviceCollection.collectionID!);
if (collection == null || collection.isDeleted) {
_logger.warning(
"Collection $deviceCollection.collectionID either deleted or missing "
"for path ${deviceCollection.id}",
);
return null;
if (collection != null && !collection.isDeleted) {
return collection.id;
}
if (collection == null) {
// ideally, this should never happen because the app keeps a track of
// all collections and their IDs. But, if somehow the collection is
// deleted, we should fetch it again
_logger.severe(
"Collection ${deviceCollection.collectionID} missing "
"for pathID ${deviceCollection.id}",
);
_collectionsService
.fetchCollectionByID(deviceCollection.collectionID!)
.ignore();
// return, by next run collection should be available.
// we are not waiting on fetch by choice because device might have wrong
// mapping which will result in breaking upload for other device path
return null;
} else if (collection.isDeleted) {
_logger.warning("Collection ${deviceCollection.collectionID} deleted "
"for pathID ${deviceCollection.id}, new collection will be created");
}
return collection.id;
} else {
final collection =
await _collectionsService.getOrCreateForPath(deviceCollection.name);
await _db.updateDeviceCollection(deviceCollection.id, collection.id);
return collection.id;
}
final collection =
await _collectionsService.getOrCreateForPath(deviceCollection.name);
await _db.updateDeviceCollection(deviceCollection.id, collection.id);
return collection.id;
}
Future<List<File>> _getFilesToBeUploaded() async {

View file

@ -2,7 +2,6 @@ import "dart:convert";
import 'package:logging/logging.dart';
import 'package:photos/core/event_bus.dart';
import 'package:photos/core/network/network.dart';
import 'package:photos/data/holidays.dart';
import 'package:photos/data/months.dart';
import 'package:photos/data/years.dart';
@ -24,7 +23,6 @@ import 'package:tuple/tuple.dart';
class SearchService {
Future<List<File>>? _cachedFilesFuture;
final _enteDio = NetworkClient.instance.enteDio;
final _logger = Logger((SearchService).toString());
final _collectionService = CollectionsService.instance;
static const _maximumResultsLimit = 20;

View file

@ -116,10 +116,14 @@ class UserService {
}
}
Future<void> sendFeedback(BuildContext context, String feedback) async {
Future<void> sendFeedback(
BuildContext context,
String feedback, {
String type = "SubCancellation",
}) async {
await _dio.post(
_config.getHttpEndpoint() + "/anonymous/feedback",
data: {"feedback": feedback},
data: {"feedback": feedback, "type": "type"},
);
}
@ -164,6 +168,10 @@ class UserService {
final userDetails = UserDetails.fromMap(response.data);
if (shouldCache) {
await _preferences.setString(keyUserDetails, userDetails.toJson());
// handle email change from different client
if (userDetails.email != _config.getEmail()) {
setEmail(userDetails.email);
}
}
return userDetails;
} on DioError catch (e) {
@ -229,13 +237,9 @@ class UserService {
Future<DeleteChallengeResponse?> getDeleteChallenge(
BuildContext context,
) async {
final dialog = createProgressDialog(context, "Please wait...");
await dialog.show();
try {
final response = await _enteDio.get("/users/delete-challenge");
if (response.statusCode == 200) {
// clear data
await dialog.hide();
return DeleteChallengeResponse(
allowDelete: response.data["allowDelete"] as bool,
encryptedChallenge: response.data["encryptedChallenge"],
@ -245,7 +249,6 @@ class UserService {
}
} catch (e) {
_logger.severe(e);
await dialog.hide();
await showGenericErrorDialog(context: context);
return null;
}
@ -253,13 +256,17 @@ class UserService {
Future<void> deleteAccount(
BuildContext context,
String challengeResponse,
) async {
String challengeResponse, {
required String reasonCategory,
required String feedback,
}) async {
try {
final response = await _enteDio.delete(
"/users/delete",
data: {
"challenge": challengeResponse,
"reasonCategory": reasonCategory,
"feedback": feedback,
},
);
if (response.statusCode == 200) {

View file

@ -1,10 +1,10 @@
import 'dart:convert';
import "package:dropdown_button2/dropdown_button2.dart";
import 'package:flutter/material.dart';
import "package:logging/logging.dart";
import 'package:photos/core/configuration.dart';
import 'package:photos/models/delete_account.dart';
import 'package:photos/services/local_authentication_service.dart';
import 'package:photos/services/user_service.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
@ -12,12 +12,32 @@ import 'package:photos/ui/components/models/button_type.dart';
import 'package:photos/utils/crypto_util.dart';
import 'package:photos/utils/dialog_util.dart';
import 'package:photos/utils/email_util.dart';
import "package:photos/utils/toast_util.dart";
class DeleteAccountPage extends StatelessWidget {
class DeleteAccountPage extends StatefulWidget {
const DeleteAccountPage({
Key? key,
}) : super(key: key);
@override
State<DeleteAccountPage> createState() => _DeleteAccountPageState();
}
class _DeleteAccountPageState extends State<DeleteAccountPage> {
bool _hasConfirmedDeletion = false;
final _feedbackTextCtrl = TextEditingController();
final String _defaultSelection = 'Select reason';
late String _dropdownValue = _defaultSelection;
late final List<String> _deletionReason = [
_defaultSelection,
'Its missing a key feature that I need',
'The app or a certain feature does not \nbehave as I think it should',
'I found another service that I like better',
'I use a different account',
'My reason isnt listed',
];
final List<int> _reasonIndexesWhereFeedbackIsNecessary = [1, 2, 5];
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
@ -38,68 +58,150 @@ class DeleteAccountPage extends StatelessWidget {
child: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
Image.asset(
'assets/broken_heart.png',
width: 200,
Text(
"What is the main reason you are deleting your account?",
style: getEnteTextTheme(context).body,
),
const SizedBox(
height: 24,
),
Center(
child: Text(
"We'll be sorry to see you go. Are you facing some issue?",
style: Theme.of(context)
.textTheme
.subtitle1!
.copyWith(color: colorScheme.textMuted),
const SizedBox(height: 4),
Container(
width: double.infinity,
height: 48,
decoration: BoxDecoration(
color: colorScheme.fillFaint,
borderRadius: BorderRadius.circular(8),
),
child: DropdownButton2<String>(
alignment: AlignmentDirectional.topStart,
value: _dropdownValue,
onChanged: (String? newValue) {
setState(() {
_dropdownValue = newValue!;
});
},
underline: const SizedBox(),
items: _deletionReason
.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
enabled: value != _defaultSelection,
alignment: Alignment.centerLeft,
child: Text(
value,
style: value != _defaultSelection
? getEnteTextTheme(context).small
: getEnteTextTheme(context).smallMuted,
),
);
}).toList(),
),
),
const SizedBox(
height: 12,
const SizedBox(height: 24),
Text(
"We are sorry to see you go. Please share your feedback to "
"help us improve.",
style: getEnteTextTheme(context).body,
),
RichText(
// textAlign: TextAlign.center,
text: TextSpan(
children: const [
TextSpan(text: "Please write to us at "),
TextSpan(
text: "feedback@ente.io",
style: TextStyle(color: Color.fromRGBO(29, 185, 84, 1)),
),
TextSpan(
text: ", maybe there is a way we can help.",
),
],
style: Theme.of(context)
.textTheme
.subtitle1!
.copyWith(color: colorScheme.textMuted),
const SizedBox(height: 4),
TextFormField(
decoration: InputDecoration(
enabledBorder: OutlineInputBorder(
borderSide:
BorderSide(color: colorScheme.strokeFaint, width: 1),
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderSide:
BorderSide(color: colorScheme.strokeFaint, width: 1),
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Colors.transparent,
hintText: "Feedback",
contentPadding: const EdgeInsets.all(12),
),
),
const SizedBox(
height: 24,
),
ButtonWidget(
buttonType: ButtonType.primary,
labelText: "Yes, send feedback",
icon: Icons.check_outlined,
onTap: () async {
await sendEmail(
context,
to: 'feedback@ente.io',
subject: '[Feedback]',
);
controller: _feedbackTextCtrl,
autofocus: false,
autocorrect: false,
keyboardType: TextInputType.multiline,
minLines: 3,
maxLines: null,
onChanged: (_) {
setState(() {});
},
),
const SizedBox(height: 8),
ButtonWidget(
buttonType: ButtonType.tertiaryCritical,
labelText: "No, delete account",
icon: Icons.no_accounts_outlined,
onTap: () async => {await _initiateDelete(context)},
shouldSurfaceExecutionStates: false,
)
_shouldAskForFeedback()
? SizedBox(
height: 42,
child: Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
"Kindly help us with this information",
style: getEnteTextTheme(context)
.smallBold
.copyWith(color: colorScheme.warning700),
),
),
)
: const SizedBox(height: 42),
GestureDetector(
onTap: () {
setState(() {
_hasConfirmedDeletion = !_hasConfirmedDeletion;
});
},
child: Row(
children: [
Checkbox(
value: _hasConfirmedDeletion,
side: CheckboxTheme.of(context).side,
onChanged: (value) {
setState(() {
_hasConfirmedDeletion = value!;
});
},
),
Expanded(
child: Text(
"Yes, I want to permanently delete this account and "
"all its data.",
style: getEnteTextTheme(context).bodyMuted,
textAlign: TextAlign.left,
),
)
],
),
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ButtonWidget(
buttonType: ButtonType.critical,
labelText: "Confirm Account Deletion",
isDisabled: _shouldBlockDeletion(),
onTap: () async {
await _initiateDelete(context);
},
shouldSurfaceExecutionStates: true,
),
const SizedBox(height: 8),
ButtonWidget(
buttonType: ButtonType.secondary,
labelText: "Cancel",
onTap: () async {
Navigator.of(context).pop();
},
),
const SafeArea(
child: SizedBox(
height: 12,
),
),
],
),
),
],
),
),
@ -107,78 +209,69 @@ class DeleteAccountPage extends StatelessWidget {
);
}
bool _shouldBlockDeletion() {
return !_hasConfirmedDeletion ||
_dropdownValue == _defaultSelection ||
_shouldAskForFeedback();
}
bool _shouldAskForFeedback() {
return (_reasonIndexesWhereFeedbackIsNecessary
.contains(_deletionReason.indexOf(_dropdownValue)) &&
_feedbackTextCtrl.text.trim().isEmpty);
}
Future<void> _initiateDelete(BuildContext context) async {
final deleteChallengeResponse =
await UserService.instance.getDeleteChallenge(context);
if (deleteChallengeResponse == null) {
return;
}
if (deleteChallengeResponse.allowDelete) {
await _confirmAndDelete(context, deleteChallengeResponse);
} else {
await _requestEmailForDeletion(context);
final choice = await showChoiceDialog(
context,
title: "Confirm Account Deletion",
body: "You are about to permanently delete your account and all its data."
"\nThis action is irreversible.",
firstButtonLabel: "Delete Account Permanently",
firstButtonType: ButtonType.critical,
firstButtonOnTap: () async {
final deleteChallengeResponse =
await UserService.instance.getDeleteChallenge(context);
if (deleteChallengeResponse == null) {
return;
}
if (deleteChallengeResponse.allowDelete) {
await _delete(context, deleteChallengeResponse);
} else {
await _requestEmailForDeletion(context);
}
},
isDismissible: false,
);
if (choice!.action == ButtonAction.error) {
await showGenericErrorDialog(context: context);
}
}
Future<void> _confirmAndDelete(
Future<void> _delete(
BuildContext context,
DeleteChallengeResponse response,
) async {
final hasAuthenticated =
await LocalAuthenticationService.instance.requestLocalAuthentication(
context,
"Please authenticate to initiate account deletion",
);
if (hasAuthenticated) {
final choice = await showChoiceDialog(
context,
title: 'Are you sure you want to delete your account?',
body:
'Your uploaded data will be scheduled for deletion, and your account'
' will be permanently deleted. \n\nThis action is not reversible.',
firstButtonLabel: "Delete my account",
isCritical: true,
firstButtonOnTap: () async {
final decryptChallenge = CryptoUtil.openSealSync(
CryptoUtil.base642bin(response.encryptedChallenge),
CryptoUtil.base642bin(
Configuration.instance.getKeyAttributes()!.publicKey,
),
Configuration.instance.getSecretKey()!,
);
final challengeResponseStr = utf8.decode(decryptChallenge);
await UserService.instance
.deleteAccount(context, challengeResponseStr);
},
try {
final decryptChallenge = CryptoUtil.openSealSync(
CryptoUtil.base642bin(response.encryptedChallenge),
CryptoUtil.base642bin(
Configuration.instance.getKeyAttributes()!.publicKey,
),
Configuration.instance.getSecretKey()!,
);
final challengeResponseStr = utf8.decode(decryptChallenge);
await UserService.instance.deleteAccount(
context,
challengeResponseStr,
reasonCategory: _dropdownValue,
feedback: _feedbackTextCtrl.text.trim(),
);
if (choice!.action == ButtonAction.error) {
showGenericErrorDialog(context: context);
}
if (choice.action != ButtonAction.first) {
return;
}
Navigator.of(context).popUntil((route) => route.isFirst);
await showTextInputDialog(
context,
title: "Your account was deleted. Would you like to leave us a note?",
submitButtonLabel: "Send",
hintText: "Optional, as short as you like...",
alwaysShowSuccessState: true,
textCapitalization: TextCapitalization.words,
onSubmit: (String text) async {
// indicates user cancelled the rename request
if (text == "" || text.trim().isEmpty) {
return;
}
try {
await UserService.instance.sendFeedback(context, text);
} catch (e, s) {
Logger("Delete account").severe("Failed to send feedback", e, s);
}
},
);
showShortToast(context, "Your account has been deleted");
} catch (e, s) {
Logger("DeleteAccount").severe("failed to delete", e, s);
showGenericErrorDialog(context: context);
}
}

View file

@ -258,7 +258,9 @@ class _EmailEntryPageState extends State<EmailEntryPage> {
autofillHints: const [AutofillHints.newPassword],
onEditingComplete: () => TextInput.finishAutofillContext(),
decoration: InputDecoration(
fillColor: _passwordsMatch ? _validFieldValueColor : null,
fillColor: _passwordsMatch && _passwordIsValid
? _validFieldValueColor
: null,
filled: true,
hintText: "Confirm password",
contentPadding: const EdgeInsets.symmetric(

View file

@ -228,7 +228,6 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
child: CollectionsListWidget(
searchResults,
widget.actionType,
widget.showOptionToCreateNewAlbum,
widget.selectedFiles,
widget.sharedFiles,
_searchQuery,

View file

@ -30,7 +30,6 @@ import 'package:receive_sharing_intent/receive_sharing_intent.dart';
class CollectionsListWidget extends StatelessWidget {
final List<CollectionWithThumbnail> collectionsWithThumbnail;
final CollectionActionType actionType;
final bool showOptionToCreateNewAlbum;
final SelectedFiles? selectedFiles;
final List<SharedMediaFile>? sharedFiles;
final String searchQuery;
@ -39,7 +38,6 @@ class CollectionsListWidget extends StatelessWidget {
CollectionsListWidget(
this.collectionsWithThumbnail,
this.actionType,
this.showOptionToCreateNewAlbum,
this.selectedFiles,
this.sharedFiles,
this.searchQuery,
@ -56,18 +54,15 @@ class CollectionsListWidget extends StatelessWidget {
: selectedFiles?.files.length ?? 0;
if (collectionsWithThumbnail.isEmpty) {
if (shouldShowCreateAlbum) {
return _getNewAlbumWidget(context, filesCount);
}
return const EmptyState();
}
return ListView.separated(
itemBuilder: (context, index) {
if (index == 0 && shouldShowCreateAlbum) {
return GestureDetector(
onTap: () async {
await _createNewAlbumOnTap(context, filesCount);
},
behavior: HitTestBehavior.opaque,
child: const NewAlbumListItemWidget(),
);
return _getNewAlbumWidget(context, filesCount);
}
final item =
collectionsWithThumbnail[index - (shouldShowCreateAlbum ? 1 : 0)];
@ -89,6 +84,16 @@ class CollectionsListWidget extends StatelessWidget {
);
}
GestureDetector _getNewAlbumWidget(BuildContext context, int filesCount) {
return GestureDetector(
onTap: () async {
await _createNewAlbumOnTap(context, filesCount);
},
behavior: HitTestBehavior.opaque,
child: const NewAlbumListItemWidget(),
);
}
Future<void> _createNewAlbumOnTap(
BuildContext context,
int filesCount,

View file

@ -1,24 +0,0 @@
import 'package:flutter/material.dart';
class DividerWithPadding extends StatelessWidget {
final double left, top, right, bottom, thickness;
const DividerWithPadding({
Key? key,
this.left = 0,
this.top = 0,
this.right = 0,
this.bottom = 0,
this.thickness = 0.5,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(left, top, right, bottom),
child: Divider(
thickness: thickness,
),
);
}
}

View file

@ -1,97 +0,0 @@
import 'package:flutter/material.dart';
import 'package:photos/utils/dialog_util.dart';
class RenameDialog extends StatefulWidget {
final String? name;
final String type;
final int maxLength;
const RenameDialog(this.name, this.type, {Key? key, this.maxLength = 100})
: super(key: key);
@override
State<RenameDialog> createState() => _RenameDialogState();
}
class _RenameDialogState extends State<RenameDialog> {
String? _newName;
@override
void initState() {
super.initState();
_newName = widget.name;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text("Enter a new name"),
content: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
decoration: InputDecoration(
hintText: '${widget.type} name',
hintStyle: const TextStyle(
color: Colors.white30,
),
contentPadding: const EdgeInsets.all(12),
),
onChanged: (value) {
setState(() {
_newName = value;
});
},
autocorrect: false,
keyboardType: TextInputType.text,
initialValue: _newName,
autofocus: true,
),
],
),
),
actions: [
TextButton(
child: const Text(
"Cancel",
style: TextStyle(
color: Colors.redAccent,
),
),
onPressed: () {
Navigator.of(context).pop(null);
},
),
TextButton(
child: Text(
"Rename",
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
onPressed: () {
if (_newName!.trim().isEmpty) {
showErrorDialog(
context,
"Empty name",
"${widget.type} name cannot be empty",
);
return;
}
if (_newName!.trim().length > widget.maxLength) {
showErrorDialog(
context,
"Name too large",
"${widget.type} name should be less than ${widget.maxLength} characters",
);
return;
}
Navigator.of(context).pop(_newName!.trim());
},
),
],
);
}
}

View file

@ -7,13 +7,13 @@ import 'package:photos/ui/components/buttons/icon_button_widget.dart';
class InfoItemWidget extends StatelessWidget {
final IconData leadingIcon;
final VoidCallback? editOnTap;
final String title;
final String? title;
final Future<List<Widget>> subtitleSection;
final bool hasChipButtons;
const InfoItemWidget({
required this.leadingIcon,
this.editOnTap,
required this.title,
this.title,
required this.subtitleSection,
this.hasChipButtons = false,
super.key,
@ -21,6 +21,53 @@ class InfoItemWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final children = <Widget>[];
if (title != null) {
children.addAll([
Text(
title!,
style: hasChipButtons
? getEnteTextTheme(context).smallMuted
: getEnteTextTheme(context).body,
),
SizedBox(height: hasChipButtons ? 8 : 4),
]);
}
children.addAll([
Flexible(
child: FutureBuilder(
future: subtitleSection,
builder: (context, snapshot) {
Widget child;
if (snapshot.hasData) {
final subtitle = snapshot.data as List<Widget>;
if (subtitle.isNotEmpty) {
child = Wrap(
runSpacing: 8,
spacing: 8,
children: subtitle,
);
} else {
child = const SizedBox.shrink();
}
} else {
child = EnteLoadingWidget(
padding: 3,
size: 11,
color: getEnteColorScheme(context).strokeMuted,
alignment: Alignment.centerLeft,
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeInOutExpo,
child: child,
);
},
),
),
]);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
@ -39,47 +86,7 @@ class InfoItemWidget extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: hasChipButtons
? getEnteTextTheme(context).smallMuted
: getEnteTextTheme(context).body,
),
SizedBox(height: hasChipButtons ? 8 : 4),
Flexible(
child: FutureBuilder(
future: subtitleSection,
builder: (context, snapshot) {
Widget child;
if (snapshot.hasData) {
final subtitle = snapshot.data as List<Widget>;
if (subtitle.isNotEmpty) {
child = Wrap(
runSpacing: 8,
spacing: 8,
children: subtitle,
);
} else {
child = const SizedBox.shrink();
}
} else {
child = EnteLoadingWidget(
padding: 3,
size: 11,
color: getEnteColorScheme(context).strokeMuted,
alignment: Alignment.centerLeft,
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeInOutExpo,
child: child,
);
},
),
),
],
children: children,
),
),
),

View file

@ -135,9 +135,10 @@ class _ApplyCodeScreenState extends State<ApplyCodeScreen> {
Logger('$runtimeType')
.severe("failed to apply referral", e);
showErrorDialogForException(
context: context,
exception: e as Exception,
apiErrorPrefix: "Failed to apply code");
context: context,
exception: e as Exception,
apiErrorPrefix: "Failed to apply code",
);
}
},
)

View file

@ -43,7 +43,7 @@ class _ReferralScreenState extends State<ReferralScreen> {
await UserService.instance.getUserDetailsV2(memoryCount: false);
final referralView =
await StorageBonusService.instance.getGateway().getReferralView();
return Tuple2(referralView, cachedUserDetails!);
return Tuple2(referralView, cachedUserDetails);
}
@override

View file

@ -384,12 +384,6 @@ class _StoreSubscriptionPageState extends State<StoreSubscriptionPage> {
margin: const EdgeInsets.only(bottom: 6),
child: Column(
children: [
_isFreePlanUser()
? Text(
"2 months free on yearly plans",
style: getEnteTextTheme(context).miniMuted,
)
: const SizedBox.shrink(),
RepaintBoundary(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@ -405,10 +399,17 @@ class _StoreSubscriptionPageState extends State<StoreSubscriptionPage> {
await _filterStorePlansForUi();
},
),
planText("Yearly", !showYearlyPlan)
planText("Yearly", !showYearlyPlan),
],
),
),
_isFreePlanUser()
? Text(
"2 months free on yearly plans",
style: getEnteTextTheme(context).miniMuted,
)
: const SizedBox.shrink(),
const Padding(padding: EdgeInsets.all(8)),
],
),
);

View file

@ -2,6 +2,7 @@ import 'dart:async';
import "package:flutter/foundation.dart";
import 'package:flutter/material.dart';
import "package:logging/logging.dart";
import 'package:photos/ente_theme_data.dart';
import 'package:photos/models/billing_plan.dart';
import 'package:photos/models/subscription.dart';
@ -55,6 +56,7 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
bool _isStripeSubscriber = false;
bool _showYearlyPlan = false;
EnteColorScheme colorScheme = darkScheme;
final Logger logger = Logger("StripeSubscriptionPage");
@override
void initState() {
@ -366,20 +368,44 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
);
}
Future<void> toggleStripeSubscription(bool isRenewCancelled) async {
// toggleStripeSubscription, based on current auto renew status, will
// toggle the auto renew status of the user's subscription
Future<void> toggleStripeSubscription(bool isAutoRenewDisabled) async {
await _dialog.show();
try {
isRenewCancelled
isAutoRenewDisabled
? await _billingService.activateStripeSubscription()
: await _billingService.cancelStripeSubscription();
await _fetchSub();
} catch (e) {
showShortToast(
context,
isRenewCancelled ? 'Failed to renew' : 'Failed to cancel',
isAutoRenewDisabled ? 'Failed to renew' : 'Failed to cancel',
);
}
await _dialog.hide();
if (!isAutoRenewDisabled && mounted) {
await showTextInputDialog(
context,
title: "Your subscription was cancelled. Would you like to share the "
"reason?",
submitButtonLabel: "Send",
hintText: "Optional, as short as you like...",
alwaysShowSuccessState: true,
textCapitalization: TextCapitalization.words,
onSubmit: (String text) async {
// indicates user cancelled the rename request
if (text == "" || text.trim().isEmpty) {
return;
}
try {
await UserService.instance.sendFeedback(context, text);
} catch (e, s) {
logger.severe("Failed to send feedback", e, s);
}
},
);
}
}
List<Widget> _getStripePlanWidgets() {
@ -492,12 +518,6 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
margin: const EdgeInsets.only(bottom: 6),
child: Column(
children: [
_isFreePlanUser()
? Text(
"2 months free on yearly plans",
style: getEnteTextTheme(context).miniMuted,
)
: const SizedBox.shrink(),
RepaintBoundary(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@ -513,10 +533,17 @@ class _StripeSubscriptionPageState extends State<StripeSubscriptionPage> {
await _filterStripeForUI();
},
),
planText("Yearly", !_showYearlyPlan)
planText("Yearly", !_showYearlyPlan),
],
),
),
_isFreePlanUser()
? Text(
"2 months free on yearly plans",
style: getEnteTextTheme(context).miniMuted,
)
: const SizedBox.shrink(),
const Padding(padding: EdgeInsets.all(8)),
],
),
);

View file

@ -7,14 +7,12 @@ import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/account/change_email_dialog.dart';
import 'package:photos/ui/account/delete_account_page.dart';
import 'package:photos/ui/account/password_entry_page.dart';
import 'package:photos/ui/account/recovery_key_page.dart';
import 'package:photos/ui/components/captioned_text_widget.dart';
import 'package:photos/ui/components/expandable_menu_item_widget.dart';
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
import "package:photos/ui/payment/subscription.dart";
import 'package:photos/ui/settings/common_settings.dart';
import 'package:photos/utils/crypto_util.dart';
import 'package:photos/utils/dialog_util.dart';
import 'package:photos/utils/navigation_util.dart';
import "package:url_launcher/url_launcher_string.dart";
class AccountSectionWidget extends StatelessWidget {
@ -35,38 +33,13 @@ class AccountSectionWidget extends StatelessWidget {
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: const CaptionedTextWidget(
title: "Recovery key",
title: "Manage subscription",
),
pressedColor: getEnteColorScheme(context).fillFaint,
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
showOnlyLoadingState: true,
onTap: () async {
final hasAuthenticated = await LocalAuthenticationService.instance
.requestLocalAuthentication(
context,
"Please authenticate to view your recovery key",
);
if (hasAuthenticated) {
String recoveryKey;
try {
recoveryKey = await _getOrCreateRecoveryKey(context);
} catch (e) {
await showGenericErrorDialog(context: context);
return;
}
unawaited(
routeToPage(
context,
RecoveryKeyPage(
recoveryKey,
"OK",
showAppBar: true,
onDone: () {},
),
),
);
}
_onManageSubscriptionTapped(context);
},
),
sectionOptionSpacing,
@ -157,7 +130,22 @@ class AccountSectionWidget extends StatelessWidget {
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
onTap: () async {
routeToPage(context, const DeleteAccountPage());
final hasAuthenticated = await LocalAuthenticationService.instance
.requestLocalAuthentication(
context,
"Please authenticate to initiate account deletion",
);
if (hasAuthenticated) {
unawaited(
Navigator.of(context).push(
MaterialPageRoute(
builder: (BuildContext context) {
return const DeleteAccountPage();
},
),
),
);
}
},
),
sectionOptionSpacing,
@ -165,12 +153,6 @@ class AccountSectionWidget extends StatelessWidget {
);
}
Future<String> _getOrCreateRecoveryKey(BuildContext context) async {
return CryptoUtil.bin2hex(
await UserService.instance.getOrCreateRecoveryKey(context),
);
}
void _onLogoutTapped(BuildContext context) {
showChoiceActionSheet(
context,
@ -182,4 +164,14 @@ class AccountSectionWidget extends StatelessWidget {
},
);
}
void _onManageSubscriptionTapped(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (BuildContext context) {
return getSubscriptionPage();
},
),
);
}
}

View file

@ -7,7 +7,6 @@ import 'package:photos/ui/components/captioned_text_widget.dart';
import 'package:photos/ui/components/expandable_menu_item_widget.dart';
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
import "package:photos/ui/growth/referral_screen.dart";
import 'package:photos/ui/payment/subscription.dart';
import 'package:photos/ui/settings/common_settings.dart';
import 'package:photos/utils/navigation_util.dart';
@ -26,18 +25,6 @@ class GeneralSectionWidget extends StatelessWidget {
Widget _getSectionOptions(BuildContext context) {
return Column(
children: [
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: const CaptionedTextWidget(
title: "Manage subscription",
),
pressedColor: getEnteColorScheme(context).fillFaint,
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
onTap: () async {
_onManageSubscriptionTapped(context);
},
),
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: const CaptionedTextWidget(
@ -84,16 +71,6 @@ class GeneralSectionWidget extends StatelessWidget {
);
}
void _onManageSubscriptionTapped(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (BuildContext context) {
return getSubscriptionPage();
},
),
);
}
Future<void> _onFamilyPlansTapped(BuildContext context) async {
final userDetails =
await UserService.instance.getUserDetailsV2(memoryCount: false);

View file

@ -8,12 +8,16 @@ import 'package:photos/events/two_factor_status_change_event.dart';
import 'package:photos/services/local_authentication_service.dart';
import 'package:photos/services/user_service.dart';
import 'package:photos/theme/ente_theme.dart';
import "package:photos/ui/account/recovery_key_page.dart";
import 'package:photos/ui/account/sessions_page.dart';
import 'package:photos/ui/components/captioned_text_widget.dart';
import 'package:photos/ui/components/expandable_menu_item_widget.dart';
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
import 'package:photos/ui/components/toggle_switch_widget.dart';
import 'package:photos/ui/settings/common_settings.dart';
import "package:photos/utils/crypto_util.dart";
import "package:photos/utils/dialog_util.dart";
import "package:photos/utils/navigation_util.dart";
class SecuritySectionWidget extends StatefulWidget {
const SecuritySectionWidget({Key? key}) : super(key: key);
@ -60,6 +64,43 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
if (_config.hasConfiguredAccount()) {
children.addAll(
[
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: const CaptionedTextWidget(
title: "Recovery key",
),
pressedColor: getEnteColorScheme(context).fillFaint,
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
showOnlyLoadingState: true,
onTap: () async {
final hasAuthenticated = await LocalAuthenticationService.instance
.requestLocalAuthentication(
context,
"Please authenticate to view your recovery key",
);
if (hasAuthenticated) {
String recoveryKey;
try {
recoveryKey = await _getOrCreateRecoveryKey(context);
} catch (e) {
await showGenericErrorDialog(context: context);
return;
}
unawaited(
routeToPage(
context,
RecoveryKeyPage(
recoveryKey,
"OK",
showAppBar: true,
onDone: () {},
),
),
);
}
},
),
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: const CaptionedTextWidget(
@ -186,4 +227,10 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
},
);
}
Future<String> _getOrCreateRecoveryKey(BuildContext context) async {
return CryptoUtil.bin2hex(
await UserService.instance.getOrCreateRecoveryKey(context),
);
}
}

View file

@ -126,7 +126,7 @@ class _FileSelectionActionWidgetState extends State<FileSelectionActionWidget> {
firstList.add(
BlurMenuItemWidget(
leadingIcon: Icons.link_outlined,
labelText: "Create link$suffix",
labelText: "Share link$suffix",
menuItemColor: colorScheme.fillFaint,
onTap: anyUploadedFiles ? _onCreatedSharedLinkClicked : null,
),

View file

@ -15,7 +15,6 @@ class ObjectsItemWidget extends StatelessWidget {
return InfoItemWidget(
key: const ValueKey("Objects"),
leadingIcon: Icons.image_search_outlined,
title: "Objects",
subtitleSection: _objectTags(file),
hasChipButtons: true,
);

View file

@ -320,6 +320,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0+3"
dropdown_button2:
dependency: "direct main"
description:
name: dropdown_button2
sha256: "4458d81bfd24207f3d58f66f78097064e02f810f94cf1bc80bf20fe7685ebc80"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
email_validator:
dependency: "direct main"
description:

View file

@ -12,7 +12,7 @@ description: ente photos application
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 0.7.33+433
version: 0.7.37+437
environment:
sdk: '>=2.17.0 <3.0.0'
@ -30,13 +30,14 @@ dependencies:
collection: # dart
computer: ^2.0.0
confetti: ^0.6.0
crypto: ^3.0.2
connectivity_plus: ^3.0.3
crypto: ^3.0.2
cupertino_icons: ^1.0.0
device_info: ^2.0.2
dio: ^4.0.6
dots_indicator: ^2.0.0
dotted_border: ^2.0.0+2
dropdown_button2: ^2.0.0
email_validator: ^2.0.1
equatable: ^2.0.5
event_bus: ^2.0.0
@ -165,7 +166,9 @@ flutter_native_splash:
flutter:
assets:
- assets/
- assets/models/
- assets/models/cocossd/
- assets/models/mobilenet/
- assets/models/scenes/
fonts:
- family: Inter
fonts: