Resolved merge conflicts

This commit is contained in:
ashilkn 2023-03-27 10:32:21 +05:30
commit 681be7b435
112 changed files with 3382 additions and 1499 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 }}

View file

@ -66,7 +66,7 @@ You can alternatively install the build from PlayStore or F-Droid.
3. Pull in all submodules with `git submodule update --init --recursive`
4. Enable repo git hooks `git config core.hooksPath hooks`
5. Setup TensorFlowLite by executing `setup.sh`
6. For Android, run `flutter build apk --release --flavor independent`
6. For Android, [setup your keystore](https://docs.flutter.dev/deployment/android#create-an-upload-keystore) and run `flutter build apk --release --flavor independent`
7. For iOS, run `flutter build ios`
<br/>

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

View file

@ -108,7 +108,7 @@ PODS:
- libwebp/mux (1.2.4):
- libwebp/demux
- libwebp/webp (1.2.4)
- local_auth (0.0.1):
- local_auth_ios (0.0.1):
- Flutter
- Mantle (2.2.0):
- Mantle/extobjc (= 2.2.0)
@ -195,7 +195,7 @@ DEPENDENCIES:
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- image_editor_common (from `.symlinks/plugins/image_editor_common/ios`)
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/ios`)
- local_auth (from `.symlinks/plugins/local_auth/ios`)
- local_auth_ios (from `.symlinks/plugins/local_auth_ios/ios`)
- media_extension (from `.symlinks/plugins/media_extension/ios`)
- motionphoto (from `.symlinks/plugins/motionphoto/ios`)
- move_to_background (from `.symlinks/plugins/move_to_background/ios`)
@ -276,8 +276,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/image_editor_common/ios"
in_app_purchase_storekit:
:path: ".symlinks/plugins/in_app_purchase_storekit/ios"
local_auth:
:path: ".symlinks/plugins/local_auth/ios"
local_auth_ios:
:path: ".symlinks/plugins/local_auth_ios/ios"
media_extension:
:path: ".symlinks/plugins/media_extension/ios"
motionphoto:
@ -346,7 +346,7 @@ SPEC CHECKSUMS:
image_editor_common: d6f6644ae4a6de80481e89fe6d0a8c49e30b4b43
in_app_purchase_storekit: 6b297e2b5eab9fa3251a492d57301722e4132a71
libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef
local_auth: 1740f55d7af0a2e2a8684ce225fe79d8931e808c
local_auth_ios: c6cf091ded637a88f24f86a8875d8b0f526e2605
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
media_extension: 6d30dc1431ebaa63f43c397c37917b1a0a597a4c
motionphoto: d4a432b8c8f22fb3ad966258597c0103c9c5ff16

View file

@ -290,7 +290,7 @@
"${BUILT_PRODUCTS_DIR}/image_editor_common/image_editor_common.framework",
"${BUILT_PRODUCTS_DIR}/in_app_purchase_storekit/in_app_purchase_storekit.framework",
"${BUILT_PRODUCTS_DIR}/libwebp/libwebp.framework",
"${BUILT_PRODUCTS_DIR}/local_auth/local_auth.framework",
"${BUILT_PRODUCTS_DIR}/local_auth_ios/local_auth_ios.framework",
"${BUILT_PRODUCTS_DIR}/media_extension/media_extension.framework",
"${BUILT_PRODUCTS_DIR}/motionphoto/motionphoto.framework",
"${BUILT_PRODUCTS_DIR}/move_to_background/move_to_background.framework",
@ -346,7 +346,7 @@
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/image_editor_common.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/in_app_purchase_storekit.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebp.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth_ios.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/media_extension.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/motionphoto.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/move_to_background.framework",

View file

@ -18,10 +18,12 @@ import "package:photos/utils/intent_util.dart";
class EnteApp extends StatefulWidget {
final Future<void> Function(String) runBackgroundTask;
final Future<void> Function(String) killBackgroundTask;
final AdaptiveThemeMode? savedThemeMode;
const EnteApp(
this.runBackgroundTask,
this.killBackgroundTask, {
this.killBackgroundTask,
this.savedThemeMode, {
Key? key,
}) : super(key: key);
@ -56,7 +58,7 @@ class _EnteAppState extends State<EnteApp> with WidgetsBindingObserver {
return AdaptiveTheme(
light: lightThemeData,
dark: darkThemeData,
initial: AdaptiveThemeMode.system,
initial: widget.savedThemeMode ?? AdaptiveThemeMode.system,
builder: (lightTheme, dartTheme) => MaterialApp(
title: "ente",
themeMode: ThemeMode.system,

View file

@ -1,6 +1,10 @@
import "package:logging/logging.dart";
import 'package:photos/events/event.dart';
class SyncStatusUpdate extends Event {
static final _logger = Logger("SyncStatusUpdate");
final SyncStatus status;
final int? completed;
final int? total;
@ -18,6 +22,7 @@ class SyncStatusUpdate extends Event {
this.reason = "",
this.error,
}) {
_logger.info("Creating sync status: " + status.toString());
timestamp = DateTime.now().microsecondsSinceEpoch;
}
}

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

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import "package:adaptive_theme/adaptive_theme.dart";
import 'package:background_fetch/background_fetch.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
@ -58,28 +59,38 @@ const kBackgroundLockLatency = Duration(seconds: 3);
void main() async {
debugRepaintRainbowEnabled = false;
WidgetsFlutterBinding.ensureInitialized();
await _runInForeground();
final savedThemeMode = await AdaptiveTheme.getThemeMode();
await _runInForeground(savedThemeMode);
BackgroundFetch.registerHeadlessTask(_headlessTaskHandler);
}
Future<void> _runInForeground() async {
Future<void> _runInForeground(AdaptiveThemeMode? savedThemeMode) async {
return await _runWithLogs(() async {
_logger.info("Starting app in foreground");
await _init(false, via: 'mainMethod');
unawaited(_scheduleFGSync('appStart in FG'));
runApp(
AppLock(
builder: (args) => const EnteApp(_runBackgroundTask, _killBGTask),
builder: (args) =>
EnteApp(_runBackgroundTask, _killBGTask, savedThemeMode),
lockScreen: const LockScreen(),
enabled: Configuration.instance.shouldShowLockScreen(),
lightTheme: lightThemeData,
darkTheme: darkThemeData,
backgroundLockLatency: kBackgroundLockLatency,
savedThemeMode: _themeMode(savedThemeMode),
),
);
});
}
ThemeMode _themeMode(AdaptiveThemeMode? savedThemeMode) {
if (savedThemeMode == null) return ThemeMode.system;
if (savedThemeMode.isLight) return ThemeMode.light;
if (savedThemeMode.isDark) return ThemeMode.dark;
return ThemeMode.system;
}
Future<void> _runBackgroundTask(String taskId, {String mode = 'normal'}) async {
if (_isProcessRunning) {
_logger.info("Background task triggered when process was already running");
@ -119,7 +130,7 @@ Future<void> _runInBackground(String taskId) async {
// https://stackoverflow.com/a/73796478/546896
@pragma('vm:entry-point')
void _headlessTaskHandler(HeadlessTask task) {
print("_headlessTaskHandler");
debugPrint("_headlessTaskHandler");
if (task.timeout) {
BackgroundFetch.finish(task.taskId);
} else {

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

@ -1,4 +1,4 @@
import "package:photos/ui/components/button_widget.dart";
import 'package:photos/ui/components/buttons/button_widget.dart';
class ButtonResult {
///action can be null when action for the button that is returned when popping

View file

@ -9,6 +9,7 @@ class UserDetails {
final String email;
final int usage;
final int fileCount;
final int storageBonus;
final int sharedCollectionsCount;
final Subscription subscription;
final FamilyData? familyData;
@ -17,6 +18,7 @@ class UserDetails {
this.email,
this.usage,
this.fileCount,
this.storageBonus,
this.sharedCollectionsCount,
this.subscription,
this.familyData,
@ -50,7 +52,8 @@ class UserDetails {
}
int getTotalStorage() {
return isPartOfFamily() ? familyData!.storage : subscription.storage;
return (isPartOfFamily() ? familyData!.storage : subscription.storage) +
storageBonus;
}
factory UserDetails.fromMap(Map<String, dynamic> map) {
@ -58,6 +61,7 @@ class UserDetails {
map['email'] as String,
map['usage'] as int,
(map['fileCount'] ?? 0) as int,
(map['storageBonus'] ?? 0) as int,
(map['sharedCollectionsCount'] ?? 0) as int,
Subscription.fromMap(map['subscription']),
FamilyData.fromMap(map['familyData']),
@ -69,6 +73,7 @@ class UserDetails {
'email': email,
'usage': usage,
'fileCount': fileCount,
'storageBonus': storageBonus,
'sharedCollectionsCount': sharedCollectionsCount,
'subscription': subscription.toMap(),
'familyData': familyData?.toMap(),

View file

@ -141,6 +141,7 @@ class CollectionsService {
for (final collection in collections) {
_cacheCollectionAttributes(collection);
}
_logger.info("Collections synced");
watch.log("collection cache refresh");
if (fetchedCollections.isNotEmpty) {
Bus.instance.fire(

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

@ -14,6 +14,12 @@ class FeatureFlagService {
static final FeatureFlagService instance =
FeatureFlagService._privateConstructor();
static const _featureFlagsKey = "feature_flags_key";
static final _internalUserIDs = const String.fromEnvironment(
"internal_user_ids",
defaultValue: "1,2,3,4,191",
).split(",").map((element) {
return int.parse(element);
}).toSet();
final _logger = Logger("FeatureFlagService");
FeatureFlags? _featureFlags;
@ -64,7 +70,10 @@ class FeatureFlagService {
bool isInternalUserOrDebugBuild() {
final String? email = Configuration.instance.getEmail();
return (email != null && email.endsWith("@ente.io")) || kDebugMode;
final userID = Configuration.instance.getUserID();
return (email != null && email.endsWith("@ente.io")) ||
_internalUserIDs.contains(userID) ||
kDebugMode;
}
Future<void> fetchFeatureFlags() async {

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

@ -23,6 +23,7 @@ import 'package:photos/models/file_type.dart';
import 'package:photos/models/upload_strategy.dart';
import 'package:photos/services/app_lifecycle_service.dart';
import 'package:photos/services/collections_service.dart';
import "package:photos/services/feature_flag_service.dart";
import 'package:photos/services/ignored_files_service.dart';
import 'package:photos/services/local_file_update_service.dart';
import 'package:photos/services/sync_service.dart';
@ -44,7 +45,7 @@ class RemoteSyncService {
int _completedUploads = 0;
late SharedPreferences _prefs;
Completer<void>? _existingSync;
bool _existingSyncSilent = false;
bool _isExistingSyncSilent = false;
static const kHasSyncedArchiveKey = "has_synced_archive";
final String _isFirstRemoteSyncDone = "isFirstRemoteSyncDone";
@ -84,13 +85,17 @@ class RemoteSyncService {
_logger.info("Remote sync already in progress, skipping");
// if current sync is silent but request sync is non-silent (demands UI
// updates), update the syncSilently flag
if (_existingSyncSilent == true && silently == false) {
_existingSyncSilent = false;
if (_isExistingSyncSilent && !silently) {
_isExistingSyncSilent = false;
}
return _existingSync?.future;
}
_existingSync = Completer<void>();
_existingSyncSilent = silently;
_isExistingSyncSilent = silently;
_logger.info(
"Starting remote sync " +
(silently ? "silently" : " with status updates"),
);
try {
// use flag to decide if we should start marking files for upload before
@ -115,18 +120,20 @@ class RemoteSyncService {
}
final filesToBeUploaded = await _getFilesToBeUploaded();
final hasUploadedFiles = await _uploadFiles(filesToBeUploaded);
_logger.info("File upload complete");
if (hasUploadedFiles) {
await _pullDiff();
_existingSync?.complete();
_existingSync = null;
await syncDeviceCollectionFilesForUpload();
final hasMoreFilesToBackup = (await _getFilesToBeUploaded()).isNotEmpty;
_logger.info("hasMoreFilesToBackup?" + hasMoreFilesToBackup.toString());
if (hasMoreFilesToBackup && !_shouldThrottleSync()) {
// Skipping a resync to ensure that files that were ignored in this
// session are not processed now
sync();
} else {
debugPrint("Fire backup completed event");
_logger.info("Fire backup completed event");
Bus.instance.fire(SyncStatusUpdate(SyncStatus.completedBackup));
}
} else {
@ -146,13 +153,17 @@ class RemoteSyncService {
rethrow;
} else {
_logger.severe("Error executing remote sync ", e, s);
if (FeatureFlagService.instance.isInternalUserOrDebugBuild()) {
rethrow;
}
}
} finally {
_existingSyncSilent = false;
_isExistingSyncSilent = false;
}
}
Future<void> _pullDiff() async {
_logger.info("Pulling remote diff");
final isFirstSync = !_collectionsService.hasSyncedCollections();
await _collectionsService.sync();
// check and reset user's collection syncTime in past for older clients
@ -179,6 +190,7 @@ class RemoteSyncService {
);
await _collectionsService.setCollectionSyncTime(c.id, c.updationTime);
}
_logger.info("All updated collections synced");
}
Future<void> _resetAllCollectionsSyncTime() async {
@ -193,7 +205,12 @@ class RemoteSyncService {
}
Future<void> _syncCollectionDiff(int collectionID, int sinceTime) async {
if (!_existingSyncSilent) {
_logger.info(
"Syncing collection #" +
collectionID.toString() +
(_isExistingSyncSilent ? " silently" : ""),
);
if (!_isExistingSyncSilent) {
Bus.instance.fire(SyncStatusUpdate(SyncStatus.applyingRemoteDiff));
}
final diff =
@ -252,10 +269,13 @@ class RemoteSyncService {
collectionID,
_collectionsService.getCollectionSyncTime(collectionID),
);
} else {
_logger.info("Collection #" + collectionID.toString() + " synced");
}
}
Future<void> syncDeviceCollectionFilesForUpload() async {
_logger.info("Syncing device collections to be uploaded");
final int ownerID = _config.getUserID()!;
final deviceCollections = await _db.getDeviceCollections();
@ -279,15 +299,15 @@ class RemoteSyncService {
if (localIDsToSync.isEmpty) {
continue;
}
await _createCollectionForDevicePath(deviceCollection);
if (deviceCollection.collectionID == -1) {
_logger.finest('DeviceCollection should not be -1 here');
final collectionID = await _getCollectionID(deviceCollection);
if (collectionID == null) {
_logger.warning('DeviceCollection was either deleted or missing');
continue;
}
moreFilesMarkedForBackup = true;
await _db.setCollectionIDForUnMappedLocalFiles(
deviceCollection.collectionID!,
collectionID,
localIDsToSync,
);
@ -295,15 +315,13 @@ class RemoteSyncService {
// the collection. This can happen when a user has marked a folder
// for sync, then un-synced it and again tries to mark if for sync.
final Set<String> existingMapping =
await _db.getLocalFileIDsForCollection(
deviceCollection.collectionID!,
);
await _db.getLocalFileIDsForCollection(collectionID);
final Set<String> commonElements =
localIDsToSync.intersection(existingMapping);
if (commonElements.isNotEmpty) {
debugPrint(
"${commonElements.length} files already existing in "
"collection ${deviceCollection.collectionID} for ${deviceCollection.name}",
"collection $collectionID for ${deviceCollection.name}",
);
localIDsToSync.removeAll(commonElements);
}
@ -324,7 +342,7 @@ class RemoteSyncService {
final String localID = existingFile.localID!;
if (!fileFoundForLocalIDs.contains(localID)) {
existingFile.generatedID = null;
existingFile.collectionID = deviceCollection.collectionID;
existingFile.collectionID = collectionID;
existingFile.uploadedFileID = null;
existingFile.ownerID = null;
newFilesToInsert.add(existingFile);
@ -414,27 +432,37 @@ class RemoteSyncService {
}
}
Future<void> _createCollectionForDevicePath(
DeviceCollection deviceCollection,
) async {
int deviceCollectionID = deviceCollection.collectionID ?? -1;
if (deviceCollectionID != -1) {
final collectionByID =
_collectionsService.getCollectionByID(deviceCollectionID);
if (collectionByID == null || collectionByID.isDeleted) {
_logger.info(
"Collection $deviceCollectionID either deleted or missing "
"for path ${deviceCollection.id}",
Future<int?> _getCollectionID(DeviceCollection deviceCollection) async {
if (deviceCollection.hasCollectionID()) {
final collection =
_collectionsService.getCollectionByID(deviceCollection.collectionID!);
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}",
);
deviceCollectionID = -1;
_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");
}
}
if (deviceCollectionID == -1) {
final collection =
await _collectionsService.getOrCreateForPath(deviceCollection.name);
await _db.updateDeviceCollection(deviceCollection.id, collection.id);
deviceCollection.collectionID = 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

@ -1,10 +1,12 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:bip39/bip39.dart' as bip39;
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:photos/core/configuration.dart';
import 'package:photos/core/constants.dart';
import 'package:photos/core/event_bus.dart';
import 'package:photos/core/network/network.dart';
import 'package:photos/db/public_keys_db.dart';
@ -23,7 +25,9 @@ import 'package:photos/ui/account/ott_verification_page.dart';
import 'package:photos/ui/account/password_entry_page.dart';
import 'package:photos/ui/account/password_reentry_page.dart';
import 'package:photos/ui/account/two_factor_authentication_page.dart';
import 'package:photos/ui/account/two_factor_recovery_page.dart';
import 'package:photos/ui/account/two_factor_setup_page.dart';
import 'package:photos/utils/crypto_util.dart';
import 'package:photos/utils/dialog_util.dart';
import 'package:photos/utils/navigation_util.dart';
import 'package:photos/utils/toast_util.dart';
@ -112,6 +116,17 @@ class UserService {
}
}
Future<void> sendFeedback(
BuildContext context,
String feedback, {
String type = "SubCancellation",
}) async {
await _dio.post(
_config.getHttpEndpoint() + "/anonymous/feedback",
data: {"feedback": feedback, "type": "type"},
);
}
// getPublicKey returns null value if email id is not
// associated with another ente account
Future<String?> getPublicKey(String email) async {
@ -153,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) {
@ -218,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"],
@ -234,7 +249,6 @@ class UserService {
}
} catch (e) {
_logger.severe(e);
await dialog.hide();
await showGenericErrorDialog(context: context);
return null;
}
@ -242,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) {
@ -489,6 +507,147 @@ class UserService {
}
}
Future<void> recoverTwoFactor(BuildContext context, String sessionID) async {
final dialog = createProgressDialog(context, "Please wait...");
await dialog.show();
try {
final response = await _dio.get(
_config.getHttpEndpoint() + "/users/two-factor/recover",
queryParameters: {
"sessionID": sessionID,
},
);
if (response.statusCode == 200) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return TwoFactorRecoveryPage(
sessionID,
response.data["encryptedSecret"],
response.data["secretDecryptionNonce"],
);
},
),
(route) => route.isFirst,
);
}
} on DioError catch (e) {
_logger.severe(e);
if (e.response != null && e.response!.statusCode == 404) {
showToast(context, "Session expired");
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return const LoginPage();
},
),
(route) => route.isFirst,
);
} else {
showErrorDialog(
context,
"Oops",
"Something went wrong, please try again",
);
}
} catch (e) {
_logger.severe(e);
showErrorDialog(
context,
"Oops",
"Something went wrong, please try again",
);
} finally {
await dialog.hide();
}
}
Future<void> removeTwoFactor(
BuildContext context,
String sessionID,
String recoveryKey,
String encryptedSecret,
String secretDecryptionNonce,
) async {
final dialog = createProgressDialog(context, "Please wait...");
await dialog.show();
String secret;
try {
if (recoveryKey.contains(' ')) {
if (recoveryKey.split(' ').length != mnemonicKeyWordCount) {
throw AssertionError(
'recovery code should have $mnemonicKeyWordCount words',
);
}
recoveryKey = bip39.mnemonicToEntropy(recoveryKey);
}
secret = CryptoUtil.bin2base64(
await CryptoUtil.decrypt(
CryptoUtil.base642bin(encryptedSecret),
CryptoUtil.hex2bin(recoveryKey.trim()),
CryptoUtil.base642bin(secretDecryptionNonce),
),
);
} catch (e) {
await dialog.hide();
await showErrorDialog(
context,
"Incorrect recovery key",
"The recovery key you entered is incorrect",
);
return;
}
try {
final response = await _dio.post(
_config.getHttpEndpoint() + "/users/two-factor/remove",
data: {
"sessionID": sessionID,
"secret": secret,
},
);
if (response.statusCode == 200) {
showShortToast(context, "Two-factor authentication successfully reset");
await _saveConfiguration(response);
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return const PasswordReentryPage();
},
),
(route) => route.isFirst,
);
}
} on DioError catch (e) {
_logger.severe(e);
if (e.response != null && e.response!.statusCode == 404) {
showToast(context, "Session expired");
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return const LoginPage();
},
),
(route) => route.isFirst,
);
} else {
showErrorDialog(
context,
"Oops",
"Something went wrong, please try again",
);
}
} catch (e) {
_logger.severe(e);
showErrorDialog(
context,
"Oops",
"Something went wrong, please try again",
);
} finally {
await dialog.hide();
}
}
Future<void> setupTwoFactor(BuildContext context, Completer completer) async {
final dialog = createProgressDialog(context, "Please wait...");
await dialog.show();
@ -518,13 +677,26 @@ class UserService {
String secret,
String code,
) async {
Uint8List recoveryKey;
try {
recoveryKey = await getOrCreateRecoveryKey(context);
} catch (e) {
showGenericErrorDialog(context: context);
return false;
}
final dialog = createProgressDialog(context, "Verifying...");
await dialog.show();
final encryptionResult =
CryptoUtil.encryptSync(CryptoUtil.base642bin(secret), recoveryKey);
try {
await _enteDio.post(
"/users/two-factor/enable",
data: {
"code": code
"code": code,
"encryptedTwoFactorSecret":
CryptoUtil.bin2base64(encryptionResult.encryptedData!),
"twoFactorSecretDecryptionNonce":
CryptoUtil.bin2base64(encryptionResult.nonce!),
},
);
await dialog.hide();

View file

@ -1,22 +1,43 @@
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/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
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);
@ -37,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,
),
),
],
),
),
],
),
),
@ -106,58 +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);
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

@ -8,7 +8,7 @@ import 'package:photos/core/event_bus.dart';
import 'package:photos/events/subscription_purchased_event.dart';
import 'package:photos/ui/account/recovery_page.dart';
import 'package:photos/ui/common/dynamic_fab.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/ui/home_widget.dart';
import 'package:photos/utils/dialog_util.dart';
import 'package:photos/utils/email_util.dart';

View file

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:photos/services/user_service.dart';
import 'package:photos/ui/lifecycle_event_handler.dart';
import "package:photos/utils/dialog_util.dart";
import 'package:pinput/pin_put/pin_put.dart';
class TwoFactorAuthenticationPage extends StatefulWidget {
@ -124,11 +123,7 @@ class _TwoFactorAuthenticationPageState
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
showErrorDialog(
context,
"Contact support",
"Please drop an email to support@ente.io from your registered email address",
);
UserService.instance.recoverTwoFactor(context, widget.sessionID);
},
child: Container(
padding: const EdgeInsets.all(10),

View file

@ -0,0 +1,110 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:photos/services/user_service.dart';
import 'package:photos/utils/dialog_util.dart';
class TwoFactorRecoveryPage extends StatefulWidget {
final String sessionID;
final String encryptedSecret;
final String secretDecryptionNonce;
const TwoFactorRecoveryPage(
this.sessionID,
this.encryptedSecret,
this.secretDecryptionNonce, {
Key? key,
}) : super(key: key);
@override
State<TwoFactorRecoveryPage> createState() => _TwoFactorRecoveryPageState();
}
class _TwoFactorRecoveryPageState extends State<TwoFactorRecoveryPage> {
final _recoveryKey = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(
"Recover account",
style: TextStyle(
fontSize: 18,
),
),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(60, 0, 60, 0),
child: TextFormField(
decoration: const InputDecoration(
hintText: "Enter your recovery key",
contentPadding: EdgeInsets.all(20),
),
style: const TextStyle(
fontSize: 14,
fontFeatures: [FontFeature.tabularFigures()],
),
controller: _recoveryKey,
autofocus: false,
autocorrect: false,
keyboardType: TextInputType.multiline,
maxLines: null,
onChanged: (_) {
setState(() {});
},
),
),
const Padding(padding: EdgeInsets.all(24)),
Container(
padding: const EdgeInsets.fromLTRB(80, 0, 80, 0),
width: double.infinity,
height: 64,
child: OutlinedButton(
onPressed: _recoveryKey.text.isNotEmpty
? () async {
await UserService.instance.removeTwoFactor(
context,
widget.sessionID,
_recoveryKey.text,
widget.encryptedSecret,
widget.secretDecryptionNonce,
);
}
: null,
child: const Text("Recover"),
),
),
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
showErrorDialog(
context,
"Contact support",
"Please drop an email to support@ente.io from your registered email address",
);
},
child: Container(
padding: const EdgeInsets.all(40),
child: Center(
child: Text(
"No recovery key?",
style: TextStyle(
decoration: TextDecoration.underline,
fontSize: 12,
color: Colors.white.withOpacity(0.9),
),
),
),
),
),
],
),
);
}
}

View file

@ -12,7 +12,7 @@ import 'package:photos/services/user_remote_flag_service.dart';
import 'package:photos/services/user_service.dart';
import 'package:photos/ui/account/recovery_key_page.dart';
import 'package:photos/ui/common/gradient_button.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/utils/crypto_util.dart';
import 'package:photos/utils/dialog_util.dart';
import 'package:photos/utils/navigation_util.dart';

View file

@ -6,7 +6,7 @@ import 'package:photos/services/favorites_service.dart';
import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
import 'package:photos/ui/common/progress_dialog.dart';
import 'package:photos/ui/components/action_sheet_widget.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/ui/components/models/button_type.dart';
import 'package:photos/utils/dialog_util.dart';
import 'package:photos/utils/toast_util.dart';

View file

@ -15,7 +15,7 @@ import 'package:photos/theme/colors.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/common/progress_dialog.dart';
import 'package:photos/ui/components/action_sheet_widget.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/ui/components/dialog_widget.dart';
import 'package:photos/ui/components/models/button_type.dart';
import 'package:photos/ui/payment/subscription.dart';

View file

@ -5,9 +5,9 @@ import "package:photos/models/file_type.dart";
import "package:photos/theme/colors.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/components/action_sheet_widget.dart";
import "package:photos/ui/components/button_widget.dart";
import 'package:photos/ui/components/buttons/button_widget.dart';
import "package:photos/ui/components/models/button_type.dart";
import "package:photos/ui/viewer/file/file_info_widget.dart";
import 'package:photos/ui/viewer/file/file_details_widget.dart';
import "package:photos/utils/delete_file_util.dart";
import "package:photos/utils/dialog_util.dart";
import "package:photos/utils/toast_util.dart";
@ -136,7 +136,7 @@ Future<void> showInfoSheet(BuildContext context, File file) async {
return Padding(
padding:
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: FileInfoWidget(file),
child: FileDetailsWidget(file),
);
},
);

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
import 'package:photos/ui/components/captioned_text_widget.dart';
import 'package:photos/ui/components/icon_button_widget.dart';
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
import 'package:photos/ui/components/title_bar_title_widget.dart';
import 'package:photos/ui/components/title_bar_widget.dart';

View file

@ -3,9 +3,9 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:photos/core/configuration.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
import 'package:photos/ui/components/captioned_text_widget.dart';
import 'package:photos/ui/components/divider_widget.dart';
import 'package:photos/ui/components/icon_button_widget.dart';
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
import 'package:photos/ui/components/menu_section_description_widget.dart';
import 'package:photos/ui/components/title_bar_title_widget.dart';

View file

@ -14,7 +14,7 @@ import 'package:photos/theme/ente_theme.dart';
import "package:photos/ui/collections_list_widget.dart";
import 'package:photos/ui/common/loading_widget.dart';
import 'package:photos/ui/components/bottom_of_title_bar_widget.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/ui/components/models/button_type.dart';
import "package:photos/ui/components/text_input_widget.dart";
import 'package:photos/ui/components/title_bar_title_widget.dart';
@ -153,7 +153,6 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
child: TextInputWidget(
hintText: "Album name",
prefixIcon: Icons.search_rounded,
autoFocus: true,
onChange: (value) {
setState(() {
_searchQuery = value;
@ -199,47 +198,46 @@ class _CollectionActionSheetState extends State<CollectionActionSheet> {
return Flexible(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 4, 0),
child: Scrollbar(
thumbVisibility: true,
radius: const Radius.circular(2),
child: Padding(
padding: const EdgeInsets.only(right: 12),
child: FutureBuilder(
future: _getCollectionsWithThumbnail(),
builder: (context, snapshot) {
if (snapshot.hasError) {
//Need to show an error on the UI here
return const SizedBox.shrink();
} else if (snapshot.hasData) {
final collectionsWithThumbnail =
snapshot.data as List<CollectionWithThumbnail>;
_removeIncomingCollections(collectionsWithThumbnail);
final shouldShowCreateAlbum =
widget.showOptionToCreateNewAlbum && _searchQuery.isEmpty;
final searchResults = _searchQuery.isNotEmpty
? collectionsWithThumbnail
.where(
(element) => element.collection.name!
.toLowerCase()
.contains(_searchQuery),
)
.toList()
: collectionsWithThumbnail;
return CollectionsListWidget(
child: FutureBuilder(
future: _getCollectionsWithThumbnail(),
builder: (context, snapshot) {
if (snapshot.hasError) {
//Need to show an error on the UI here
return const SizedBox.shrink();
} else if (snapshot.hasData) {
final collectionsWithThumbnail =
snapshot.data as List<CollectionWithThumbnail>;
_removeIncomingCollections(collectionsWithThumbnail);
final shouldShowCreateAlbum =
widget.showOptionToCreateNewAlbum && _searchQuery.isEmpty;
final searchResults = _searchQuery.isNotEmpty
? collectionsWithThumbnail
.where(
(element) => element.collection.name!
.toLowerCase()
.contains(_searchQuery),
)
.toList()
: collectionsWithThumbnail;
return Scrollbar(
thumbVisibility: true,
radius: const Radius.circular(2),
child: Padding(
padding: const EdgeInsets.only(right: 12),
child: CollectionsListWidget(
searchResults,
widget.actionType,
widget.showOptionToCreateNewAlbum,
widget.selectedFiles,
widget.sharedFiles,
_searchQuery,
shouldShowCreateAlbum,
);
} else {
return const EnteLoadingWidget();
}
},
),
),
),
),
);
} else {
return const EnteLoadingWidget();
}
},
),
),
);

View file

@ -21,7 +21,7 @@ import 'package:photos/ui/collections/section_title.dart';
import 'package:photos/ui/collections/trash_button_widget.dart';
import 'package:photos/ui/collections/uncat_collections_button_widget.dart';
import 'package:photos/ui/common/loading_widget.dart';
import "package:photos/ui/components/icon_button_widget.dart";
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
import 'package:photos/ui/viewer/actions/delete_empty_albums.dart';
import 'package:photos/ui/viewer/gallery/empty_state.dart';
import 'package:photos/utils/local_settings.dart';

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

@ -3,17 +3,25 @@ import 'package:photos/theme/ente_theme.dart';
class EnteLoadingWidget extends StatelessWidget {
final Color? color;
final bool is20pts;
const EnteLoadingWidget({this.is20pts = false, this.color, Key? key})
: super(key: key);
final double size;
final double padding;
final Alignment alignment;
const EnteLoadingWidget({
this.color,
this.size = 14,
this.padding = 5,
this.alignment = Alignment.center,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
return Align(
alignment: alignment,
child: Padding(
padding: EdgeInsets.all(is20pts ? 3 : 5),
padding: EdgeInsets.all(padding),
child: SizedBox.fromSize(
size: const Size.square(14),
size: Size.square(size),
child: CircularProgressIndicator(
strokeWidth: 2,
color: color ?? getEnteColorScheme(context).strokeBase,

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,7 +7,7 @@ import "package:photos/models/search/button_result.dart";
import 'package:photos/theme/colors.dart';
import 'package:photos/theme/effects.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/utils/separators_util.dart';
enum ActionSheetType {

View file

@ -8,7 +8,7 @@ import 'package:photos/models/selected_files.dart';
import 'package:photos/theme/effects.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/components/bottom_action_bar/action_bar_widget.dart';
import 'package:photos/ui/components/icon_button_widget.dart';
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
class BottomActionBarWidget extends StatelessWidget {
final String? text;

View file

@ -350,7 +350,7 @@ class _ButtonChildWidgetState extends State<ButtonChildWidget> {
},
),
EnteLoadingWidget(
is20pts: true,
padding: 3,
color: loadingIconColor,
),
],

View file

@ -0,0 +1,54 @@
import "package:flutter/material.dart";
import "package:photos/theme/ente_theme.dart";
///https://www.figma.com/file/SYtMyLBs5SAOkTbfMMzhqt/ente-Visual-Design?node-id=8119%3A59513&t=gQa1to5jY89Qk1k7-4
class ChipButtonWidget extends StatelessWidget {
final String? label;
final IconData? leadingIcon;
final VoidCallback? onTap;
final bool noChips;
const ChipButtonWidget(
this.label, {
this.leadingIcon,
this.onTap,
this.noChips = false,
super.key,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap?.call,
child: Container(
width: noChips ? double.infinity : null,
decoration: BoxDecoration(
color: getEnteColorScheme(context).fillFaint,
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
leadingIcon != null
? Icon(
leadingIcon,
size: 17,
)
: const SizedBox.shrink(),
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
label ?? "",
style: getEnteTextTheme(context).smallBold,
),
)
],
),
),
),
);
}
}

View file

@ -43,6 +43,7 @@ class _IconButtonWidgetState extends State<IconButtonWidget> {
@override
Widget build(BuildContext context) {
final bool hasPressedState = widget.onTap != null;
final colorTheme = getEnteColorScheme(context);
iconStateColor ??
(iconStateColor = widget.defaultColor ??
@ -52,9 +53,9 @@ class _IconButtonWidgetState extends State<IconButtonWidget> {
return widget.disableGestureDetector
? _iconButton(colorTheme)
: GestureDetector(
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
onTapDown: hasPressedState ? _onTapDown : null,
onTapUp: hasPressedState ? _onTapUp : null,
onTapCancel: hasPressedState ? _onTapCancel : null,
onTap: widget.onTap,
child: _iconButton(colorTheme),
);

View file

@ -0,0 +1,20 @@
import "package:flutter/cupertino.dart";
import "package:photos/theme/ente_theme.dart";
class InlineButtonWidget extends StatelessWidget {
final String label;
final VoidCallback? onTap;
final TextStyle? textStyle;
const InlineButtonWidget(this.label, this.onTap, {this.textStyle, super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap?.call,
child: Text(
label,
style: textStyle ?? getEnteTextTheme(context).smallMuted,
),
);
}
}

View file

@ -7,7 +7,7 @@ import 'package:photos/models/typedefs.dart';
import 'package:photos/theme/colors.dart';
import 'package:photos/theme/effects.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/ui/components/models/button_type.dart';
import 'package:photos/ui/components/text_input_widget.dart';
import 'package:photos/utils/separators_util.dart';

View file

@ -11,15 +11,21 @@ enum DividerType {
class DividerWidget extends StatelessWidget {
final DividerType dividerType;
final Color bgColor;
final bool divColorHasBlur;
final EdgeInsets? padding;
const DividerWidget({
required this.dividerType,
this.bgColor = Colors.transparent,
this.divColorHasBlur = true,
this.padding,
super.key,
});
@override
Widget build(BuildContext context) {
final dividerColor = getEnteColorScheme(context).blurStrokeFaint;
final dividerColor = divColorHasBlur
? getEnteColorScheme(context).blurStrokeFaint
: getEnteColorScheme(context).strokeFaint;
if (dividerType == DividerType.solid) {
return Container(
@ -38,6 +44,7 @@ class DividerWidget extends StatelessWidget {
return Container(
color: bgColor,
padding: padding ?? EdgeInsets.zero,
child: Row(
children: [
SizedBox(

View file

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:photos/ui/components/icon_button_widget.dart';
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
import 'package:photos/ui/viewer/search/search_widget.dart';
class HomeHeaderWidget extends StatefulWidget {

View file

@ -0,0 +1,106 @@
import "package:flutter/material.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/common/loading_widget.dart";
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
///https://www.figma.com/file/SYtMyLBs5SAOkTbfMMzhqt/ente-Visual-Design?node-id=8113-59605&t=OMX5f5KdDJYWSQQN-4
class InfoItemWidget extends StatelessWidget {
final IconData leadingIcon;
final VoidCallback? editOnTap;
final String? title;
final Future<List<Widget>> subtitleSection;
final bool hasChipButtons;
const InfoItemWidget({
required this.leadingIcon,
this.editOnTap,
this.title,
required this.subtitleSection,
this.hasChipButtons = false,
super.key,
});
@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,
children: [
Flexible(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButtonWidget(
icon: leadingIcon,
iconButtonType: IconButtonType.secondary,
),
Flexible(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 3.5, 16, 3.5),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
),
),
],
),
),
editOnTap != null
? IconButtonWidget(
icon: Icons.edit,
iconButtonType: IconButtonType.secondary,
onTap: editOnTap,
)
: const SizedBox.shrink(),
],
);
}
}

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:photos/theme/colors.dart';
import 'package:photos/theme/text_style.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
enum ButtonType {
primary,

View file

@ -3,7 +3,7 @@ import 'package:photos/ente_theme_data.dart';
import 'package:photos/theme/colors.dart';
import "package:photos/theme/ente_theme.dart";
import 'package:photos/theme/text_style.dart';
import 'package:photos/ui/components/icon_button_widget.dart';
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
// CreateNotificationType enum
enum NotificationType {

View file

@ -77,9 +77,11 @@ class _TextInputWidgetState extends State<TextInputWidget> {
selection: TextSelection.collapsed(offset: widget.initialValue!.length),
);
}
_textController.addListener(() {
widget.onChange!.call(_textController.text);
});
if (widget.onChange != null) {
_textController.addListener(() {
widget.onChange!.call(_textController.text);
});
}
_obscureTextNotifier = ValueNotifier(widget.isPasswordInput);
_obscureTextNotifier.addListener(_safeRefresh);
super.initState();

View file

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/components/icon_button_widget.dart';
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
class TitleBarWidget extends StatelessWidget {
final IconButtonWidget? leading;

View file

@ -5,8 +5,8 @@ import "package:photos/models/api/storage_bonus/storage_bonus.dart";
import "package:photos/models/user_details.dart";
import "package:photos/services/storage_bonus_service.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/components/button_widget.dart";
import "package:photos/ui/components/icon_button_widget.dart";
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
import "package:photos/ui/components/models/button_type.dart";
import "package:photos/ui/components/title_bar_title_widget.dart";
import "package:photos/ui/components/title_bar_widget.dart";
@ -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

@ -3,8 +3,8 @@ import "package:flutter_animate/flutter_animate.dart";
import "package:photos/models/api/storage_bonus/storage_bonus.dart";
import "package:photos/models/user_details.dart";
import "package:photos/theme/ente_theme.dart";
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
import "package:photos/ui/components/captioned_text_widget.dart";
import "package:photos/ui/components/icon_button_widget.dart";
import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart";
import "package:photos/ui/components/title_bar_title_widget.dart";
import "package:photos/ui/components/title_bar_widget.dart";

View file

@ -6,9 +6,9 @@ import "package:photos/services/user_service.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/common/loading_widget.dart";
import "package:photos/ui/common/web_page.dart";
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
import "package:photos/ui/components/captioned_text_widget.dart";
import "package:photos/ui/components/divider_widget.dart";
import "package:photos/ui/components/icon_button_widget.dart";
import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart";
import "package:photos/ui/components/title_bar_title_widget.dart";
import "package:photos/ui/components/title_bar_widget.dart";
@ -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

@ -6,7 +6,7 @@ import "package:photos/models/user_details.dart";
import "package:photos/services/storage_bonus_service.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/common/loading_widget.dart";
import "package:photos/ui/components/icon_button_widget.dart";
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
import "package:photos/ui/components/title_bar_title_widget.dart";
import "package:photos/ui/components/title_bar_widget.dart";
import "package:photos/utils/data_util.dart";

View file

@ -10,7 +10,7 @@ import 'package:photos/ui/account/login_page.dart';
import 'package:photos/ui/account/password_entry_page.dart';
import 'package:photos/ui/account/password_reentry_page.dart';
import 'package:photos/ui/common/gradient_button.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/ui/components/dialog_widget.dart';
import 'package:photos/ui/components/models/button_type.dart';
import 'package:photos/ui/payment/subscription.dart';

View file

@ -2,9 +2,9 @@ import "dart:io";
import "package:flutter/cupertino.dart";
import 'package:flutter/material.dart';
import 'package:photos/ente_theme_data.dart';
import 'package:photos/models/memory.dart';
import 'package:photos/services/memories_service.dart';
import "package:photos/theme/ente_theme.dart";
import "package:photos/theme/text_style.dart";
import "package:photos/ui/actions/file/file_actions.dart";
import "package:photos/ui/extents_page_view.dart";
@ -120,10 +120,7 @@ class _MemoryWidgetState extends State<MemoryWidget> {
type: MaterialType.transparency,
child: Text(
title,
style: Theme.of(context)
.textTheme
.subtitle1!
.copyWith(fontSize: 12),
style: getEnteTextTheme(context).mini,
textAlign: TextAlign.center,
),
),
@ -136,22 +133,21 @@ class _MemoryWidgetState extends State<MemoryWidget> {
}
Container _buildMemoryItem(BuildContext context, int index) {
final colorScheme = getEnteColorScheme(context);
final memory = widget.memories[index];
final isSeen = memory.isSeen();
return Container(
decoration: BoxDecoration(
border: isSeen
? const Border()
: Border.all(
color: Theme.of(context).colorScheme.greenAlternative,
width: isSeen ? 0 : 2,
),
border: Border.all(
color: isSeen ? colorScheme.strokeFaint : colorScheme.primary500,
width: 2,
),
borderRadius: BorderRadius.circular(40),
),
child: ClipOval(
child: SizedBox(
width: isSeen ? 60 : 56,
height: isSeen ? 60 : 56,
width: 56,
height: 56,
child: Hero(
tag: "memories" + memory.file.tag,
child: ThumbnailWidget(

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import "package:logging/logging.dart";
import 'package:photos/core/event_bus.dart';
import 'package:photos/ente_theme_data.dart';
import 'package:photos/events/notification_event.dart';
@ -24,6 +25,8 @@ class StatusBarWidget extends StatefulWidget {
}
class _StatusBarWidgetState extends State<StatusBarWidget> {
static final _logger = Logger("StatusBarWidget");
late StreamSubscription<SyncStatusUpdate> _subscription;
late StreamSubscription<NotificationEvent> _notificationSubscription;
bool _showStatus = false;
@ -33,6 +36,7 @@ class _StatusBarWidgetState extends State<StatusBarWidget> {
@override
void initState() {
_subscription = Bus.instance.on<SyncStatusUpdate>().listen((event) {
_logger.info("Received event " + event.toString());
if (event.status == SyncStatus.error) {
setState(() {
_syncError = event.error;

View file

@ -2,7 +2,7 @@ import "package:flutter/material.dart";
import "package:photos/core/constants.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/collection_action_sheet.dart";
import "package:photos/ui/components/button_widget.dart";
import 'package:photos/ui/components/buttons/button_widget.dart';
import "package:photos/ui/components/empty_state_item_widget.dart";
import "package:photos/ui/components/models/button_type.dart";
import "package:photos/utils/share_util.dart";

View file

@ -3,7 +3,7 @@ import "dart:io";
import 'package:flutter/material.dart';
import 'package:photos/services/update_service.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/ui/components/divider_widget.dart';
import 'package:photos/ui/components/models/button_type.dart';
import 'package:photos/ui/components/title_bar_title_widget.dart';

View file

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:photos/models/user_details.dart';
import 'package:photos/services/user_service.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/utils/dialog_util.dart';
class ChildSubscriptionWidget extends StatelessWidget {

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';
@ -14,7 +15,7 @@ import 'package:photos/ui/common/bottom_shadow.dart';
import 'package:photos/ui/common/loading_widget.dart';
import 'package:photos/ui/common/progress_dialog.dart';
import 'package:photos/ui/common/web_page.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import "package:photos/ui/components/captioned_text_widget.dart";
import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart";
import 'package:photos/ui/payment/child_subscription_widget.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

@ -5,7 +5,7 @@ import 'package:photos/models/collection.dart';
import 'package:photos/services/collections_service.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/ui/components/captioned_text_widget.dart';
import 'package:photos/ui/components/divider_widget.dart';
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';

View file

@ -4,7 +4,7 @@ import 'package:photos/services/collections_service.dart';
import 'package:photos/theme/colors.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/ui/components/captioned_text_widget.dart';
import 'package:photos/ui/components/divider_widget.dart';
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';

View file

@ -10,7 +10,7 @@ import "package:photos/core/configuration.dart";
import "package:photos/services/user_service.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/common/loading_widget.dart";
import "package:photos/ui/components/button_widget.dart";
import 'package:photos/ui/components/buttons/button_widget.dart';
import "package:photos/ui/components/models/button_type.dart";
import "package:photos/utils/share_util.dart";

View file

@ -32,11 +32,13 @@ class AppLock extends StatefulWidget {
final Duration backgroundLockLatency;
final ThemeData? darkTheme;
final ThemeData? lightTheme;
final ThemeMode savedThemeMode;
const AppLock({
Key? key,
required this.builder,
required this.lockScreen,
required this.savedThemeMode,
this.enabled = true,
this.backgroundLockLatency = const Duration(seconds: 0),
this.darkTheme,
@ -103,7 +105,7 @@ class _AppLockState extends State<AppLock> with WidgetsBindingObserver {
return MaterialApp(
home: this.widget.enabled ? this._lockScreen : this.widget.builder(null),
navigatorKey: _navigatorKey,
themeMode: ThemeMode.system,
themeMode: widget.savedThemeMode,
theme: widget.lightTheme,
darkTheme: widget.darkTheme,
supportedLocales: AppLocalizations.supportedLocales,

View file

@ -7,8 +7,8 @@ import 'package:photos/core/cache/video_cache_manager.dart';
import 'package:photos/core/configuration.dart';
import 'package:photos/services/feature_flag_service.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
import 'package:photos/ui/components/captioned_text_widget.dart';
import 'package:photos/ui/components/icon_button_widget.dart';
import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
import 'package:photos/ui/components/menu_section_title.dart';
import 'package:photos/ui/components/title_bar_title_widget.dart';

View file

@ -16,7 +16,7 @@ import 'package:photos/models/location.dart';
import 'package:photos/services/sync_service.dart';
import 'package:photos/ui/common/loading_widget.dart';
import 'package:photos/ui/components/action_sheet_widget.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/ui/components/models/button_type.dart';
import 'package:photos/ui/tools/editor/filtered_image.dart';
import 'package:photos/ui/viewer/file/detail_page.dart';

View file

@ -5,7 +5,7 @@ import 'package:photos/models/collection.dart';
import 'package:photos/models/file.dart';
import 'package:photos/services/collections_service.dart';
import 'package:photos/ui/components/action_sheet_widget.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/ui/components/models/button_type.dart';
class DeleteEmptyAlbums extends StatefulWidget {

View file

@ -18,7 +18,7 @@ import 'package:photos/ui/collection_action_sheet.dart';
import 'package:photos/ui/components/action_sheet_widget.dart';
import 'package:photos/ui/components/blur_menu_item_widget.dart';
import 'package:photos/ui/components/bottom_action_bar/expanded_menu_widget.dart';
import 'package:photos/ui/components/button_widget.dart';
import 'package:photos/ui/components/buttons/button_widget.dart';
import 'package:photos/ui/components/models/button_type.dart';
import 'package:photos/ui/sharing/manage_links_widget.dart';
import 'package:photos/utils/delete_file_util.dart';
@ -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

@ -7,7 +7,7 @@ import 'package:photos/models/selected_files.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/collection_action_sheet.dart';
import 'package:photos/ui/components/bottom_action_bar/bottom_action_bar_widget.dart';
import 'package:photos/ui/components/icon_button_widget.dart';
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
import 'package:photos/ui/viewer/actions/file_selection_actions_widget.dart';
import 'package:photos/utils/delete_file_util.dart';
import 'package:photos/utils/magic_util.dart';

View file

@ -1,68 +0,0 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:photos/models/collection.dart';
import 'package:photos/models/collection_items.dart';
import 'package:photos/models/gallery_type.dart';
import 'package:photos/services/collections_service.dart';
import 'package:photos/ui/common/loading_widget.dart';
import 'package:photos/ui/viewer/file/file_info_collection_widget.dart';
import 'package:photos/ui/viewer/gallery/collection_page.dart';
import 'package:photos/utils/navigation_util.dart';
class CollectionsListOfFileWidget extends StatelessWidget {
final Future<Set<int>> allCollectionIDsOfFile;
final int currentUserID;
const CollectionsListOfFileWidget(
this.allCollectionIDsOfFile,
this.currentUserID, {
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return FutureBuilder<Set<int>>(
future: allCollectionIDsOfFile,
builder: (context, snapshot) {
if (snapshot.hasData) {
final Set<int> collectionIDs = snapshot.data!;
final collections = <Collection>[];
for (var collectionID in collectionIDs) {
final c =
CollectionsService.instance.getCollectionByID(collectionID);
collections.add(c!);
}
return ListView.builder(
itemCount: collections.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
final bool isHidden = collections[index].isHidden();
return FileInfoCollectionWidget(
name: isHidden ? 'Hidden' : collections[index].name,
onTap: () {
if (isHidden) {
return;
}
routeToPage(
context,
CollectionPage(
CollectionWithThumbnail(collections[index], null),
appBarType: collections[index].isOwner(currentUserID)
? GalleryType.ownedCollection
: GalleryType.sharedCollection,
),
);
},
);
},
);
} else if (snapshot.hasError) {
Logger("CollectionsListOfFile").info(snapshot.error);
return const SizedBox.shrink();
} else {
return const EnteLoadingWidget();
}
},
);
}
}

View file

@ -1,37 +0,0 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:photos/ui/common/loading_widget.dart';
import 'package:photos/ui/viewer/file/file_info_collection_widget.dart';
class DeviceFoldersListOfFileWidget extends StatelessWidget {
final Future<Set<String>> allDeviceFoldersOfFile;
const DeviceFoldersListOfFileWidget(this.allDeviceFoldersOfFile, {Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
return FutureBuilder<Set<String>>(
future: allDeviceFoldersOfFile,
builder: (context, snapshot) {
if (snapshot.hasData) {
final List<String> deviceFolders = snapshot.data!.toList();
return ListView.builder(
itemCount: deviceFolders.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
return FileInfoCollectionWidget(
name: deviceFolders[index],
onTap: () {},
);
},
);
} else if (snapshot.hasError) {
Logger("DeviceFoldersListOfFile").info(snapshot.error);
return const SizedBox.shrink();
} else {
return const EnteLoadingWidget();
}
},
);
}
}

View file

@ -2,31 +2,34 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:photos/models/file.dart';
import "package:photos/theme/ente_theme.dart";
import 'package:photos/ui/common/loading_widget.dart';
import 'package:photos/utils/exif_util.dart';
class ExifInfoDialog extends StatefulWidget {
class ExifInfoDialog extends StatelessWidget {
final File file;
const ExifInfoDialog(this.file, {Key? key}) : super(key: key);
@override
State<ExifInfoDialog> createState() => _ExifInfoDialogState();
}
class _ExifInfoDialogState extends State<ExifInfoDialog> {
@override
Widget build(BuildContext context) {
final scrollController = ScrollController();
final textTheme = getEnteTextTheme(context);
return AlertDialog(
title: Text(
widget.file.title!,
style: Theme.of(context).textTheme.headline5,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"EXIF",
style: textTheme.h3Bold,
),
Text(
file.title!,
style: textTheme.smallMuted,
),
],
),
content: Scrollbar(
controller: scrollController,
thumbVisibility: true,
child: SingleChildScrollView(
controller: scrollController,
child: _getInfo(),
),
),
@ -34,7 +37,7 @@ class _ExifInfoDialogState extends State<ExifInfoDialog> {
TextButton(
child: Text(
"Close",
style: Theme.of(context).textTheme.bodyText1,
style: textTheme.body,
),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop('dialog');
@ -46,7 +49,7 @@ class _ExifInfoDialogState extends State<ExifInfoDialog> {
Widget _getInfo() {
return FutureBuilder(
future: getExif(widget.file),
future: getExif(file),
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.hasData) {
final exif = snapshot.data;

View file

@ -0,0 +1,255 @@
import "package:exif/exif.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
import "package:photos/core/configuration.dart";
import "package:photos/models/file.dart";
import "package:photos/models/file_type.dart";
import "package:photos/services/feature_flag_service.dart";
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/components/buttons/icon_button_widget.dart';
import "package:photos/ui/components/divider_widget.dart";
import 'package:photos/ui/components/title_bar_widget.dart';
import 'package:photos/ui/viewer/file/file_caption_widget.dart';
import "package:photos/ui/viewer/file_details/added_by_widget.dart";
import "package:photos/ui/viewer/file_details/albums_item_widget.dart";
import 'package:photos/ui/viewer/file_details/backed_up_time_item_widget.dart';
import "package:photos/ui/viewer/file_details/creation_time_item_widget.dart";
import 'package:photos/ui/viewer/file_details/exif_item_widgets.dart';
import "package:photos/ui/viewer/file_details/file_properties_item_widget.dart";
import "package:photos/ui/viewer/file_details/objects_item_widget.dart";
import "package:photos/utils/exif_util.dart";
class FileDetailsWidget extends StatefulWidget {
final File file;
const FileDetailsWidget(
this.file, {
Key? key,
}) : super(key: key);
@override
State<FileDetailsWidget> createState() => _FileDetailsWidgetState();
}
class _FileDetailsWidgetState extends State<FileDetailsWidget> {
final ValueNotifier<Map<String, IfdTag>?> _exifNotifier = ValueNotifier(null);
final Map<String, dynamic> _exifData = {
"focalLength": null,
"fNumber": null,
"resolution": null,
"takenOnDevice": null,
"exposureTime": null,
"ISO": null,
"megaPixels": null
};
bool _isImage = false;
late int _currentUserID;
bool showExifListTile = false;
@override
void initState() {
debugPrint('file_details_sheet initState');
_currentUserID = Configuration.instance.getUserID()!;
_isImage = widget.file.fileType == FileType.image ||
widget.file.fileType == FileType.livePhoto;
if (_isImage) {
_exifNotifier.addListener(() {
if (_exifNotifier.value != null) {
_generateExifForDetails(_exifNotifier.value!);
}
showExifListTile = _exifData["focalLength"] != null ||
_exifData["fNumber"] != null ||
_exifData["takenOnDevice"] != null ||
_exifData["exposureTime"] != null ||
_exifData["ISO"] != null;
});
getExif(widget.file).then((exif) {
_exifNotifier.value = exif;
});
}
super.initState();
}
@override
void dispose() {
_exifNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final file = widget.file;
final bool isFileOwner =
file.ownerID == null || file.ownerID == _currentUserID;
//Make sure the bottom most tile is always the same one, that is it should
//not be rendered only if a condition is met.
final fileDetailsTiles = <Widget>[];
fileDetailsTiles.add(
!widget.file.isUploaded ||
(!isFileOwner && (widget.file.caption?.isEmpty ?? true))
? const SizedBox(height: 16)
: Padding(
padding: const EdgeInsets.only(top: 8, bottom: 24),
child: isFileOwner
? FileCaptionWidget(file: widget.file)
: FileCaptionReadyOnly(caption: widget.file.caption!),
),
);
fileDetailsTiles.addAll([
CreationTimeItem(file, _currentUserID),
const FileDetailsDivider(),
ValueListenableBuilder(
valueListenable: _exifNotifier,
builder: (context, _, __) => FilePropertiesItemWidget(
file,
_isImage,
_exifData,
_currentUserID,
),
),
const FileDetailsDivider(),
]);
fileDetailsTiles.add(
ValueListenableBuilder(
valueListenable: _exifNotifier,
builder: (context, value, _) {
return showExifListTile
? Column(
children: [
BasicExifItemWidget(_exifData),
const FileDetailsDivider(),
],
)
: const SizedBox.shrink();
},
),
);
if (_isImage) {
fileDetailsTiles.addAll([
ValueListenableBuilder(
valueListenable: _exifNotifier,
builder: (context, value, _) {
return Column(
children: [
AllExifItemWidget(file, _exifNotifier.value),
const FileDetailsDivider(),
],
);
},
)
]);
}
if (FeatureFlagService.instance.isInternalUserOrDebugBuild()) {
fileDetailsTiles.addAll([
ObjectsItemWidget(file),
const FileDetailsDivider(),
]);
}
if (file.uploadedFileID != null && file.updationTime != null) {
fileDetailsTiles.addAll(
[
BackedUpTimeItemWidget(file),
const FileDetailsDivider(),
],
);
}
fileDetailsTiles.add(AlbumsItemWidget(file, _currentUserID));
return SafeArea(
top: false,
child: Scrollbar(
thickness: 4,
radius: const Radius.circular(2),
thumbVisibility: true,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: CustomScrollView(
physics: const ClampingScrollPhysics(),
shrinkWrap: true,
slivers: <Widget>[
TitleBarWidget(
isFlexibleSpaceDisabled: true,
title: "Details",
isOnTopOfScreen: false,
backgroundColor: getEnteColorScheme(context).backgroundElevated,
leading: IconButtonWidget(
icon: Icons.expand_more_outlined,
iconButtonType: IconButtonType.primary,
onTap: () => Navigator.pop(context),
),
),
SliverToBoxAdapter(
child: AddedByWidget(
widget.file,
_currentUserID,
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return fileDetailsTiles[index];
},
childCount: fileDetailsTiles.length,
),
)
],
),
),
),
);
}
_generateExifForDetails(Map<String, IfdTag> exif) {
if (exif["EXIF FocalLength"] != null) {
_exifData["focalLength"] =
(exif["EXIF FocalLength"]!.values.toList()[0] as Ratio).numerator /
(exif["EXIF FocalLength"]!.values.toList()[0] as Ratio)
.denominator;
}
if (exif["EXIF FNumber"] != null) {
_exifData["fNumber"] =
(exif["EXIF FNumber"]!.values.toList()[0] as Ratio).numerator /
(exif["EXIF FNumber"]!.values.toList()[0] as Ratio).denominator;
}
final imageWidth = exif["EXIF ExifImageWidth"] ?? exif["Image ImageWidth"];
final imageLength = exif["EXIF ExifImageLength"] ??
exif["Image "
"ImageLength"];
if (imageWidth != null && imageLength != null) {
_exifData["resolution"] = '$imageWidth x $imageLength';
_exifData['megaPixels'] =
((imageWidth.values.firstAsInt() * imageLength.values.firstAsInt()) /
1000000)
.toStringAsFixed(1);
} else {
debugPrint("No image width/height");
}
if (exif["Image Make"] != null && exif["Image Model"] != null) {
_exifData["takenOnDevice"] =
exif["Image Make"].toString() + " " + exif["Image Model"].toString();
}
if (exif["EXIF ExposureTime"] != null) {
_exifData["exposureTime"] = exif["EXIF ExposureTime"].toString();
}
if (exif["EXIF ISOSpeedRatings"] != null) {
_exifData['ISO'] = exif["EXIF ISOSpeedRatings"].toString();
}
}
}
class FileDetailsDivider extends StatelessWidget {
const FileDetailsDivider({super.key});
@override
Widget build(BuildContext context) {
const dividerPadding = EdgeInsets.symmetric(vertical: 15.5);
return const DividerWidget(
dividerType: DividerType.menu,
divColorHasBlur: false,
padding: dividerPadding,
);
}
}

View file

@ -1,42 +0,0 @@
import 'package:flutter/material.dart';
import 'package:photos/ente_theme_data.dart';
class FileInfoCollectionWidget extends StatelessWidget {
final String? name;
final Function? onTap;
const FileInfoCollectionWidget({this.name, this.onTap, Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap as void Function()?,
child: Container(
margin: const EdgeInsets.only(
top: 10,
bottom: 18,
right: 8,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.inverseBackgroundColor
.withOpacity(0.025),
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
),
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
name!,
style: Theme.of(context).textTheme.subtitle2,
overflow: TextOverflow.ellipsis,
),
),
),
),
);
}
}

View file

@ -1,536 +0,0 @@
import "dart:async";
import "package:exif/exif.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
import 'package:flutter_datetime_picker/flutter_datetime_picker.dart';
import 'package:path/path.dart' as path;
import 'package:photo_manager/photo_manager.dart';
import "package:photos/core/configuration.dart";
import 'package:photos/db/files_db.dart';
import "package:photos/ente_theme_data.dart";
import "package:photos/models/file.dart";
import "package:photos/models/file_type.dart";
import 'package:photos/services/collections_service.dart';
import "package:photos/services/feature_flag_service.dart";
import "package:photos/services/location_service.dart";
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/components/divider_widget.dart';
import 'package:photos/ui/components/icon_button_widget.dart';
import 'package:photos/ui/components/title_bar_widget.dart';
import 'package:photos/ui/viewer/file/collections_list_of_file_widget.dart';
import 'package:photos/ui/viewer/file/device_folders_list_of_file_widget.dart';
import 'package:photos/ui/viewer/file/file_caption_widget.dart';
import "package:photos/ui/viewer/file/location_chip.dart";
import "package:photos/ui/viewer/file/locations_list.dart";
import "package:photos/ui/viewer/file/object_tags_widget.dart";
import 'package:photos/ui/viewer/file/raw_exif_list_tile_widget.dart';
import "package:photos/utils/date_time_util.dart";
import "package:photos/utils/exif_util.dart";
import "package:photos/utils/file_util.dart";
import "package:photos/utils/magic_util.dart";
class FileInfoWidget extends StatefulWidget {
final File file;
const FileInfoWidget(
this.file, {
Key? key,
}) : super(key: key);
@override
State<FileInfoWidget> createState() => _FileInfoWidgetState();
}
class _FileInfoWidgetState extends State<FileInfoWidget> {
Map<String, IfdTag>? _exif;
late LocationService locationService = LocationService.instance;
final Map<String, dynamic> _exifData = {
"focalLength": null,
"fNumber": null,
"resolution": null,
"takenOnDevice": null,
"exposureTime": null,
"ISO": null,
"megaPixels": null
};
bool _isImage = false;
int? _currentUserID;
@override
void initState() {
debugPrint('file_info_dialog initState');
_currentUserID = Configuration.instance.getUserID();
_isImage = widget.file.fileType == FileType.image ||
widget.file.fileType == FileType.livePhoto;
if (_isImage) {
getExif(widget.file).then((exif) {
if (mounted) {
setState(() {
_exif = exif;
});
}
});
}
super.initState();
}
@override
Widget build(BuildContext context) {
final file = widget.file;
final fileIsBackedup = file.uploadedFileID == null ? false : true;
final bool isFileOwner =
file.ownerID == null || file.ownerID == _currentUserID;
late Future<Set<int>> allCollectionIDsOfFile;
//Typing this as Future<Set<T>> as it would be easier to implement showing multiple device folders for a file in the future
final Future<Set<String>> allDeviceFoldersOfFile =
Future.sync(() => {file.deviceFolder ?? ''});
if (fileIsBackedup) {
allCollectionIDsOfFile = FilesDB.instance.getAllCollectionIDsOfFile(
file.uploadedFileID!,
);
}
final dateTime = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!);
final dateTimeForUpdationTime =
DateTime.fromMicrosecondsSinceEpoch(file.updationTime!);
if (_isImage && _exif != null) {
_generateExifForDetails(_exif!);
}
final bool showExifListTile = _exifData["focalLength"] != null ||
_exifData["fNumber"] != null ||
_exifData["takenOnDevice"] != null ||
_exifData["exposureTime"] != null ||
_exifData["ISO"] != null;
final bool showDimension =
_exifData["resolution"] != null && _exifData["megaPixels"] != null;
final listTiles = <Widget?>[
!widget.file.isUploaded ||
(!isFileOwner && (widget.file.caption?.isEmpty ?? true))
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: isFileOwner
? FileCaptionWidget(file: widget.file)
: FileCaptionReadyOnly(caption: widget.file.caption!),
),
ListTile(
horizontalTitleGap: 2,
leading: const Padding(
padding: EdgeInsets.only(top: 8),
child: Icon(Icons.calendar_today_rounded),
),
title: Text(
getFullDate(
DateTime.fromMicrosecondsSinceEpoch(file.creationTime!),
),
),
subtitle: Text(
getTimeIn12hrFormat(dateTime) + " " + dateTime.timeZoneName,
style: Theme.of(context).textTheme.bodyText2!.copyWith(
color: Theme.of(context)
.colorScheme
.defaultTextColor
.withOpacity(0.5),
),
),
trailing: (widget.file.ownerID == null ||
widget.file.ownerID == _currentUserID) &&
widget.file.uploadedFileID != null
? IconButton(
onPressed: () {
_showDateTimePicker(widget.file);
},
icon: const Icon(Icons.edit),
)
: const SizedBox.shrink(),
),
ListTile(
horizontalTitleGap: 2,
leading: _isImage
? const Padding(
padding: EdgeInsets.only(top: 8),
child: Icon(
Icons.image,
),
)
: const Padding(
padding: EdgeInsets.only(top: 8),
child: Icon(
Icons.video_camera_back,
size: 27,
),
),
title: Text(
path.basenameWithoutExtension(file.displayName) +
path.extension(file.displayName).toUpperCase(),
),
subtitle: Wrap(
children: [
showDimension
? Text(
"${_exifData["megaPixels"]}MP "
"${_exifData["resolution"]} ",
)
: const SizedBox.shrink(),
_getFileSize(),
(file.fileType == FileType.video) &&
(file.localID != null || file.duration != 0)
? Padding(
padding: const EdgeInsets.only(left: 8.0),
child: _getVideoDuration(),
)
: const SizedBox.shrink(),
],
),
trailing: file.uploadedFileID == null || file.ownerID != _currentUserID
? const SizedBox.shrink()
: IconButton(
onPressed: () async {
await editFilename(context, file);
setState(() {});
},
icon: const Icon(Icons.edit),
),
),
showExifListTile
? ListTile(
horizontalTitleGap: 2,
leading: const Icon(Icons.camera_rounded),
title: Text(_exifData["takenOnDevice"] ?? "--"),
subtitle: Wrap(
children: [
_exifData["fNumber"] != null
? Padding(
padding: const EdgeInsets.only(right: 10),
child: Text('ƒ/' + _exifData["fNumber"].toString()),
)
: const SizedBox.shrink(),
_exifData["exposureTime"] != null
? Padding(
padding: const EdgeInsets.only(right: 10),
child: Text(_exifData["exposureTime"]),
)
: const SizedBox.shrink(),
_exifData["focalLength"] != null
? Padding(
padding: const EdgeInsets.only(right: 10),
child:
Text(_exifData["focalLength"].toString() + "mm"),
)
: const SizedBox.shrink(),
_exifData["ISO"] != null
? Padding(
padding: const EdgeInsets.only(right: 10),
child: Text("ISO" + _exifData["ISO"].toString()),
)
: const SizedBox.shrink(),
],
),
)
: null,
SizedBox(
height: 62,
child: ListTile(
horizontalTitleGap: 0,
leading: const Icon(Icons.folder_outlined),
title: fileIsBackedup
? CollectionsListOfFileWidget(
allCollectionIDsOfFile,
_currentUserID!,
)
: DeviceFoldersListOfFileWidget(allDeviceFoldersOfFile),
),
),
FeatureFlagService.instance.isInternalUserOrDebugBuild()
? SizedBox(
height: 62,
child: ListTile(
horizontalTitleGap: 0,
leading: const Icon(Icons.image_search),
title: ObjectTagsWidget(file),
),
)
: null,
(file.uploadedFileID != null && file.updationTime != null)
? ListTile(
horizontalTitleGap: 2,
leading: const Padding(
padding: EdgeInsets.only(top: 8),
child: Icon(Icons.cloud_upload_outlined),
),
title: Text(
getFullDate(
DateTime.fromMicrosecondsSinceEpoch(file.updationTime!),
),
),
subtitle: Text(
getTimeIn12hrFormat(dateTimeForUpdationTime) +
" " +
dateTimeForUpdationTime.timeZoneName,
style: Theme.of(context).textTheme.bodyText2!.copyWith(
color: Theme.of(context)
.colorScheme
.defaultTextColor
.withOpacity(0.5),
),
),
)
: null,
ListTile(
horizontalTitleGap: 2,
leading: Padding(
padding: const EdgeInsets.only(top: 8),
child: locationService.getLocationsByFileID(file.generatedID!).isEmpty
? const Icon(Icons.add_location_alt_rounded)
: const Icon(Icons.location_on_rounded),
),
title: Text(
locationService.getLocationsByFileID(file.generatedID!).isEmpty
? "Add Location"
: "Locations",
),
subtitle:
locationService.getLocationsByFileID(file.generatedID!).isEmpty
? Text(
"group nearby photos",
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Theme.of(context)
.colorScheme
.defaultTextColor
.withOpacity(0.5),
),
)
: locationChipList(
file.generatedID!,
context,
),
trailing:
locationService.getLocationsByFileID(file.generatedID!).isEmpty
? IconButton(
onPressed: () async {
unawaited(
Navigator.of(context).push(
MaterialPageRoute(
builder: (BuildContext context) {
return LocationsList(
state: 1,
fileId: file.generatedID,
);
},
),
),
);
},
icon: const Icon(Icons.arrow_forward_ios),
)
: const SizedBox.shrink(),
),
_isImage ? RawExifListTileWidget(_exif, widget.file) : null,
];
listTiles.removeWhere(
(element) => element == null,
);
return SafeArea(
top: false,
child: Scrollbar(
thickness: 4,
radius: const Radius.circular(2),
thumbVisibility: true,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: CustomScrollView(
physics: const ClampingScrollPhysics(),
shrinkWrap: true,
slivers: <Widget>[
TitleBarWidget(
isFlexibleSpaceDisabled: true,
title: "Details",
isOnTopOfScreen: false,
backgroundColor: getEnteColorScheme(context).backgroundElevated,
leading: IconButtonWidget(
icon: Icons.close_outlined,
iconButtonType: IconButtonType.primary,
onTap: () => Navigator.pop(context),
),
),
SliverToBoxAdapter(child: addedBy(widget.file)),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index.isOdd) {
return index == 1
? const SizedBox.shrink()
: const DividerWidget(
dividerType: DividerType.menu,
);
} else {
return listTiles[index ~/ 2];
}
},
childCount: (listTiles.length * 2) - 1,
),
)
],
),
),
),
);
}
Widget addedBy(File file) {
if (file.uploadedFileID == null) {
return const SizedBox.shrink();
}
String? addedBy;
if (file.ownerID == _currentUserID) {
if (file.pubMagicMetadata!.uploaderName != null) {
addedBy = file.pubMagicMetadata!.uploaderName;
}
} else {
final fileOwner = CollectionsService.instance
.getFileOwner(file.ownerID!, file.collectionID);
addedBy = fileOwner.email;
}
if (addedBy == null || addedBy.isEmpty) {
return const SizedBox.shrink();
}
final enteTheme = Theme.of(context).colorScheme.enteTheme;
return Padding(
padding: const EdgeInsets.only(top: 4.0, bottom: 4.0, left: 16),
child: Text(
"Added by $addedBy",
style: enteTheme.textTheme.mini
.copyWith(color: enteTheme.colorScheme.textMuted),
),
);
}
_generateExifForDetails(Map<String, IfdTag> exif) {
if (exif["EXIF FocalLength"] != null) {
_exifData["focalLength"] =
(exif["EXIF FocalLength"]!.values.toList()[0] as Ratio).numerator /
(exif["EXIF FocalLength"]!.values.toList()[0] as Ratio)
.denominator;
}
if (exif["EXIF FNumber"] != null) {
_exifData["fNumber"] =
(exif["EXIF FNumber"]!.values.toList()[0] as Ratio).numerator /
(exif["EXIF FNumber"]!.values.toList()[0] as Ratio).denominator;
}
final imageWidth = exif["EXIF ExifImageWidth"] ?? exif["Image ImageWidth"];
final imageLength = exif["EXIF ExifImageLength"] ??
exif["Image "
"ImageLength"];
if (imageWidth != null && imageLength != null) {
_exifData["resolution"] = '$imageWidth x $imageLength';
_exifData['megaPixels'] =
((imageWidth.values.firstAsInt() * imageLength.values.firstAsInt()) /
1000000)
.toStringAsFixed(1);
} else {
debugPrint("No image width/height");
}
if (exif["Image Make"] != null && exif["Image Model"] != null) {
_exifData["takenOnDevice"] =
exif["Image Make"].toString() + " " + exif["Image Model"].toString();
}
if (exif["EXIF ExposureTime"] != null) {
_exifData["exposureTime"] = exif["EXIF ExposureTime"].toString();
}
if (exif["EXIF ISOSpeedRatings"] != null) {
_exifData['ISO'] = exif["EXIF ISOSpeedRatings"].toString();
}
}
Widget _getFileSize() {
Future<int> fileSizeFuture;
if (widget.file.fileSize != null) {
fileSizeFuture = Future.value(widget.file.fileSize);
} else {
fileSizeFuture = getFile(widget.file).then((f) => f!.length());
}
return FutureBuilder<int>(
future: fileSizeFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
return Text(
(snapshot.data! / (1024 * 1024)).toStringAsFixed(2) + " MB",
);
} else {
return Center(
child: SizedBox.fromSize(
size: const Size.square(24),
child: const CupertinoActivityIndicator(
radius: 8,
),
),
);
}
},
);
}
Widget _getVideoDuration() {
if (widget.file.duration != 0) {
return Text(
secondsToHHMMSS(widget.file.duration!),
);
}
return FutureBuilder<AssetEntity?>(
future: widget.file.getAsset,
builder: (context, snapshot) {
if (snapshot.hasData) {
return Text(
snapshot.data!.videoDuration.toString().split(".")[0],
);
} else {
return Center(
child: SizedBox.fromSize(
size: const Size.square(24),
child: const CupertinoActivityIndicator(
radius: 8,
),
),
);
}
},
);
}
void _showDateTimePicker(File file) async {
final dateResult = await DatePicker.showDatePicker(
context,
minTime: DateTime(1800, 1, 1),
maxTime: DateTime.now(),
currentTime: DateTime.fromMicrosecondsSinceEpoch(file.creationTime!),
locale: LocaleType.en,
theme: Theme.of(context).colorScheme.dateTimePickertheme,
);
if (dateResult == null) {
return;
}
final dateWithTimeResult = await DatePicker.showTime12hPicker(
context,
showTitleActions: true,
currentTime: dateResult,
locale: LocaleType.en,
theme: Theme.of(context).colorScheme.dateTimePickertheme,
);
if (dateWithTimeResult != null) {
if (await editTime(
context,
List.of([widget.file]),
dateWithTimeResult.microsecondsSinceEpoch,
)) {
widget.file.creationTime = dateWithTimeResult.microsecondsSinceEpoch;
setState(() {});
}
}
}
}

View file

@ -1,77 +0,0 @@
import "package:flutter/material.dart";
import "package:logging/logging.dart";
import "package:photos/ente_theme_data.dart";
import "package:photos/models/file.dart";
import "package:photos/services/object_detection/object_detection_service.dart";
import "package:photos/ui/common/loading_widget.dart";
import "package:photos/utils/thumbnail_util.dart";
class ObjectTagsWidget extends StatelessWidget {
final File file;
const ObjectTagsWidget(this.file, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return FutureBuilder<List<String>>(
future: getThumbnail(file).then((data) {
return ObjectDetectionService.instance.predict(data!);
}),
builder: (context, snapshot) {
if (snapshot.hasData) {
final List<String> tags = snapshot.data!;
if (tags.isEmpty) {
return const ObjectTagWidget("No Results");
}
return ListView.builder(
itemCount: tags.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
return ObjectTagWidget(tags[index]);
},
);
} else if (snapshot.hasError) {
Logger("ObjectTagsWidget").severe(snapshot.error);
return const Icon(Icons.error);
} else {
return const EnteLoadingWidget();
}
},
);
}
}
class ObjectTagWidget extends StatelessWidget {
final String name;
const ObjectTagWidget(this.name, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(
top: 10,
bottom: 18,
right: 8,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.inverseBackgroundColor
.withOpacity(0.025),
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
),
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
name!,
style: Theme.of(context).textTheme.subtitle2,
overflow: TextOverflow.ellipsis,
),
),
),
);
}
}

View file

@ -1,69 +0,0 @@
import 'package:exif/exif.dart';
import 'package:flutter/material.dart';
import 'package:photos/ente_theme_data.dart';
import "package:photos/models/file.dart";
import 'package:photos/ui/viewer/file/exif_info_dialog.dart';
import 'package:photos/utils/toast_util.dart';
enum Status {
loading,
exifIsAvailable,
noExif,
}
class RawExifListTileWidget extends StatelessWidget {
final File file;
final Map<String, IfdTag>? exif;
const RawExifListTileWidget(this.exif, this.file, {Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
Status exifStatus = Status.loading;
if (exif == null) {
exifStatus = Status.loading;
} else if (exif!.isNotEmpty) {
exifStatus = Status.exifIsAvailable;
} else {
exifStatus = Status.noExif;
}
return GestureDetector(
onTap: exifStatus == Status.exifIsAvailable
? () {
showDialog(
context: context,
builder: (BuildContext context) {
return ExifInfoDialog(file);
},
barrierColor: Colors.black87,
);
}
: exifStatus == Status.noExif
? () {
showShortToast(context, "This image has no exif data");
}
: null,
child: ListTile(
horizontalTitleGap: 2,
leading: const Padding(
padding: EdgeInsets.only(top: 8),
child: Icon(Icons.feed_outlined),
),
title: const Text("EXIF"),
subtitle: Text(
exifStatus == Status.loading
? "Loading EXIF data.."
: exifStatus == Status.exifIsAvailable
? "View all EXIF data"
: "No EXIF data",
style: Theme.of(context).textTheme.bodyText2!.copyWith(
color: Theme.of(context)
.colorScheme
.defaultTextColor
.withOpacity(0.5),
),
),
),
);
}
}

View file

@ -0,0 +1,37 @@
import "package:flutter/material.dart";
import "package:photos/models/file.dart";
import "package:photos/services/collections_service.dart";
import "package:photos/theme/ente_theme.dart";
class AddedByWidget extends StatelessWidget {
final File file;
final int currentUserID;
const AddedByWidget(this.file, this.currentUserID, {super.key});
@override
Widget build(BuildContext context) {
if (file.uploadedFileID == null) {
return const SizedBox.shrink();
}
String? addedBy;
if (file.ownerID == currentUserID) {
if (file.pubMagicMetadata!.uploaderName != null) {
addedBy = file.pubMagicMetadata!.uploaderName;
}
} else {
final fileOwner = CollectionsService.instance
.getFileOwner(file.ownerID!, file.collectionID);
addedBy = fileOwner.email;
}
if (addedBy == null || addedBy.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 4.0, bottom: 4.0, left: 16),
child: Text(
"Added by $addedBy",
style: getEnteTextTheme(context).miniMuted,
),
);
}
}

View file

@ -0,0 +1,109 @@
import "package:flutter/material.dart";
import "package:logging/logging.dart";
import "package:photos/db/files_db.dart";
import "package:photos/models/collection.dart";
import "package:photos/models/collection_items.dart";
import "package:photos/models/file.dart";
import "package:photos/models/gallery_type.dart";
import "package:photos/services/collections_service.dart";
import "package:photos/ui/components/buttons/chip_button_widget.dart";
import "package:photos/ui/components/info_item_widget.dart";
import "package:photos/ui/viewer/gallery/collection_page.dart";
import "package:photos/utils/navigation_util.dart";
class AlbumsItemWidget extends StatelessWidget {
final File file;
final int currentUserID;
const AlbumsItemWidget(
this.file,
this.currentUserID, {
super.key,
});
@override
Widget build(BuildContext context) {
final fileIsBackedup = file.uploadedFileID == null ? false : true;
late Future<Set<int>> allCollectionIDsOfFile;
//Typing this as Future<Set<T>> as it would be easier to implement showing multiple device folders for a file in the future
final Future<Set<String>> allDeviceFoldersOfFile =
Future.sync(() => {file.deviceFolder ?? ''});
if (fileIsBackedup) {
allCollectionIDsOfFile = FilesDB.instance.getAllCollectionIDsOfFile(
file.uploadedFileID!,
);
}
return InfoItemWidget(
key: const ValueKey("Albums"),
leadingIcon: Icons.folder_outlined,
title: "Albums",
subtitleSection: fileIsBackedup
? _collectionsListOfFile(
context,
allCollectionIDsOfFile,
currentUserID,
)
: _deviceFoldersListOfFile(allDeviceFoldersOfFile),
hasChipButtons: true,
);
}
Future<List<ChipButtonWidget>> _deviceFoldersListOfFile(
Future<Set<String>> allDeviceFoldersOfFile,
) async {
try {
final chipButtons = <ChipButtonWidget>[];
final List<String> deviceFolders =
(await allDeviceFoldersOfFile).toList();
for (var deviceFolder in deviceFolders) {
chipButtons.add(
ChipButtonWidget(
deviceFolder,
),
);
}
return chipButtons;
} catch (e, s) {
Logger("AlbumsItemWidget").info(e, s);
return [];
}
}
Future<List<ChipButtonWidget>> _collectionsListOfFile(
BuildContext context,
Future<Set<int>> allCollectionIDsOfFile,
int currentUserID,
) async {
try {
final chipButtons = <ChipButtonWidget>[];
final Set<int> collectionIDs = await allCollectionIDsOfFile;
final collections = <Collection>[];
for (var collectionID in collectionIDs) {
final c = CollectionsService.instance.getCollectionByID(collectionID);
collections.add(c!);
chipButtons.add(
ChipButtonWidget(
c.isHidden() ? "Hidden" : c.name,
onTap: () {
if (c.isHidden()) {
return;
}
routeToPage(
context,
CollectionPage(
CollectionWithThumbnail(c, null),
appBarType: c.isOwner(currentUserID)
? GalleryType.ownedCollection
: GalleryType.sharedCollection,
),
);
},
),
);
}
return chipButtons;
} catch (e, s) {
Logger("AlbumsItemWidget").info(e, s);
return [];
}
}
}

View file

@ -0,0 +1,31 @@
import "package:flutter/material.dart";
import "package:photos/models/file.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/components/info_item_widget.dart";
import "package:photos/utils/date_time_util.dart";
class BackedUpTimeItemWidget extends StatelessWidget {
final File file;
const BackedUpTimeItemWidget(this.file, {super.key});
@override
Widget build(BuildContext context) {
final dateTimeForUpdationTime =
DateTime.fromMicrosecondsSinceEpoch(file.updationTime!);
return InfoItemWidget(
key: const ValueKey("Backedup date"),
leadingIcon: Icons.backup_outlined,
title: getFullDate(
DateTime.fromMicrosecondsSinceEpoch(file.updationTime!),
),
subtitleSection: Future.value([
Text(
getTimeIn12hrFormat(dateTimeForUpdationTime) +
" " +
dateTimeForUpdationTime.timeZoneName,
style: getEnteTextTheme(context).smallMuted,
),
]),
);
}
}

Some files were not shown because too many files have changed in this diff Show more