Compare commits

..

17 Commits

Author SHA1 Message Date
Yaros 156277c629 chore: add datestringcodec 2026-05-13 22:25:42 +02:00
Yaros 9e76f09c91 chore: locale toLanguageTag 2026-05-13 20:47:28 +02:00
Yaros 955f491a66 refactor: move model to domain 2026-05-13 18:56:00 +02:00
Yaros c64767034d fix: parse dateOption 2026-05-13 18:53:46 +02:00
Yaros 2382427488 refactor: move options to mapconfig 2026-05-13 18:48:50 +02:00
Yaros d65226e325 Merge branch 'main' into feat/custom-date-range 2026-05-13 18:46:08 +02:00
Yaros 86ff373752 chore: restrict selection 2026-05-13 18:36:46 +02:00
Yaros 6bd001d9ff fix: context.locale 2026-05-13 18:36:17 +02:00
Yaros 179e72da7a fix: ifPresent 2026-05-13 18:22:14 +02:00
Yaros 21506090a5 refactor: suggestions 2026-05-13 18:10:59 +02:00
Yaros 12c4ee83d6 refactor: implement suggestions 2026-05-08 15:59:41 +02:00
Yaros 7956756d38 Merge branch 'main' into feat/custom-date-range 2026-05-07 17:49:07 +02:00
Yaros 589e0a7bc5 Merge branch 'main' into feat/custom-date-range 2026-02-26 13:10:18 +01:00
Yaros 2424952b9a refactor: add back setRelativeTime 2026-02-19 14:11:41 +01:00
Yaros 733100f6ec refactor: rename customtimerange variables 2026-02-19 14:08:50 +01:00
Yaros b0f6d5cf38 refactor: rename timerange & remove isvalid 2026-02-19 13:23:40 +01:00
Yaros 39d2e14d3a feat(mobile): custom date range for map 2026-02-14 09:56:09 +01:00
15 changed files with 323 additions and 456 deletions
+2 -9
View File
@@ -17,11 +17,6 @@ on:
description: 'Bump mobile build number'
required: false
type: boolean
rc:
description: 'Release candidate'
required: false
default: false
type: boolean
skipTranslations:
description: 'Skip translations'
required: false
@@ -79,8 +74,7 @@ jobs:
env:
SERVER_BUMP: ${{ inputs.serverBump }}
MOBILE_BUMP: ${{ inputs.mobileBump }}
RC: ${{ inputs.rc }}
run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}" -r "${RC}"
run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
- id: output
run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
@@ -114,7 +108,7 @@ jobs:
with:
ref: ${{ needs.bump_version.outputs.ref }}
environment: ${{ inputs.rc && 'rc' || 'production' }}
environment: production
prepare_release:
runs-on: ubuntu-latest
@@ -146,7 +140,6 @@ jobs:
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
draft: true
prerelease: ${{ inputs.rc }}
tag_name: ${{ needs.bump_version.outputs.version }}
token: ${{ steps.generate-token.outputs.token }}
generate_release_notes: true
+1
View File
@@ -1664,6 +1664,7 @@
"not_available": "N/A",
"not_in_any_album": "Not in any album",
"not_selected": "Not selected",
"not_set": "Not set",
"notes": "Notes",
"nothing_here_yet": "Nothing here yet",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
+26 -81
View File
@@ -1,42 +1,23 @@
#!/usr/bin/env bash
#
# Pump one or both of the server/mobile versions in appropriate files.
# Pump one or both of the server/mobile versions in appropriate files
#
# Usage:
# ./misc/release/pump-version.sh [-s <major|minor|patch>] [-m <true|false>] [-r <true|false>]
# usage: './scripts/pump-version.sh -s <major|minor|patch> <-m> <true|false>
#
# Flags:
# -s <major|minor|patch> Server version bump scope. Omit to leave the server
# version unchanged, except when finalizing an RC (see -r).
# (default: false)
# -m <true|false> Whether to increment the mobile build number.
# (default: false)
# -r <true|false> Release candidate mode. When true, starts a new RC
# (combined with -s) or iterates an existing one. When
# false while the current version is already an RC,
# finalizes it (e.g. 3.1.0-rc.2 => 3.1.0). A server bump
# is rejected while on an RC; finalize first.
# (default: false)
#
# Examples:
# ./misc/release/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
# ./misc/release/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
# ./misc/release/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
# ./misc/release/pump-version.sh -s minor -m true -r true # 3.0.0 => 3.1.0-rc.0 (start RC)
# ./misc/release/pump-version.sh -m true -r true # 3.1.0-rc.0 => 3.1.0-rc.1 (iterate RC)
# ./misc/release/pump-version.sh -m true # 3.1.0-rc.1 => 3.1.0 (finalize RC)
# examples:
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
#
SERVER_PUMP="false"
MOBILE_PUMP="false"
RC="false"
while getopts 's:m:r:' flag; do
while getopts 's:m:' flag; do
case "${flag}" in
s) SERVER_PUMP=${OPTARG} ;;
m) MOBILE_PUMP=${OPTARG} ;;
r) RC=${OPTARG} ;;
*)
echo "Invalid args"
exit 1
@@ -44,60 +25,28 @@ while getopts 's:m:r:' flag; do
esac
done
if [[ "$RC" != "true" && "$RC" != "false" ]]; then
echo "Expected <true|false> for the -r argument"
exit 1
fi
CURRENT_SERVER=$(jq -r '.version' server/package.json)
MAJOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f1)
MINOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f2)
PATCH=$(echo "$CURRENT_SERVER" | cut -d '.' -f3)
if [[ "$CURRENT_SERVER" == *-rc.* ]]; then
CURRENT_BASE="${CURRENT_SERVER%-rc.*}"
CURRENT_RC_NUM="${CURRENT_SERVER##*-rc.}"
if [[ $SERVER_PUMP == "major" ]]; then
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
elif [[ $SERVER_PUMP == "minor" ]]; then
MINOR=$((MINOR + 1))
PATCH=0
elif [[ $SERVER_PUMP == "patch" ]]; then
PATCH=$((PATCH + 1))
elif [[ $SERVER_PUMP == "false" ]]; then
echo 'Skipping Server Pump'
else
CURRENT_BASE="$CURRENT_SERVER"
CURRENT_RC_NUM=""
fi
# Validate RC/server-bump combinations against current version state
if [[ -n "$CURRENT_RC_NUM" ]]; then
# Currently on an RC: -r true iterates, -r false finalizes. Either way, a server bump is invalid.
if [[ "$SERVER_PUMP" != "false" ]]; then
echo "Cannot bump server while on an RC ($CURRENT_SERVER); finalize first by re-running with -r false and no -s."
exit 1
fi
else
# Not currently on an RC
if [[ "$RC" == "true" && "$SERVER_PUMP" == "false" ]]; then
echo "Starting an RC requires a server bump."
exit 1
fi
fi
if [[ "$SERVER_PUMP" != "major" && "$SERVER_PUMP" != "minor" && "$SERVER_PUMP" != "patch" && "$SERVER_PUMP" != "false" ]]; then
echo 'Expected <major|minor|patch|false> for the server argument'
exit 1
fi
NEXT_SERVER="$CURRENT_SERVER"
if [[ "$SERVER_PUMP" == "false" && "$RC" == "false" && -z "$CURRENT_RC_NUM" ]]; then
echo 'Skipping Server Pump'
else
npm version "$CURRENT_SERVER" --allow-same-version --no-git-tag-version || exit 1
if [[ "$RC" == "true" && -n "$CURRENT_RC_NUM" ]]; then
npm version prerelease --no-git-tag-version || exit 1
elif [[ "$RC" == "true" ]]; then
npm version "pre$SERVER_PUMP" --preid=rc --no-git-tag-version || exit 1
elif [[ -n "$CURRENT_RC_NUM" ]]; then
# rc=false while on an RC → finalize
npm version "$CURRENT_BASE" --no-git-tag-version || exit 1
else
npm version "$SERVER_PUMP" --no-git-tag-version || exit 1
fi
NEXT_SERVER=$(jq -r '.version' package.json)
fi
NEXT_SERVER=$MAJOR.$MINOR.$PATCH
CURRENT_MOBILE=$(grep "^version: .*+[0-9]\+$" mobile/pubspec.yaml | cut -d "+" -f2)
NEXT_MOBILE=$CURRENT_MOBILE
@@ -113,6 +62,7 @@ fi
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
pnpm version "$NEXT_SERVER" --no-git-tag-version
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix server
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix packages/cli
pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix web
@@ -122,12 +72,9 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
# copy version to open-api spec
mise run //:open-api
NEXT_PY="${NEXT_SERVER//-rc./rc}"
uv version --directory machine-learning "$NEXT_PY"
uv version --directory machine-learning "$NEXT_SERVER"
if [[ "$NEXT_SERVER" != *-rc.* ]]; then
./misc/release/archive-version.js "$NEXT_SERVER"
fi
./misc/release/archive-version.js "$NEXT_SERVER"
fi
if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
@@ -137,9 +84,7 @@ fi
sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile
sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
# iOS marketing version cannot contain a pre-release suffix; the plist always holds the base version.
IOS_NEXT="${NEXT_SERVER%-rc.*}"
perl -i -p0e "s/(<key>CFBundleShortVersionString<\/key>\s*<string>)$CURRENT_BASE(<\/string>)/\${1}$IOS_NEXT\${2}/s" mobile/ios/Runner/Info.plist
perl -i -p0e "s/(<key>CFBundleShortVersionString<\/key>\s*<string>)$CURRENT_SERVER(<\/string>)/\${1}$NEXT_SERVER\${2}/s" mobile/ios/Runner/Info.plist
echo "IMMICH_VERSION=v$NEXT_SERVER" >>"$GITHUB_ENV"
-310
View File
@@ -1,310 +0,0 @@
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert/strict';
import test from 'node:test';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, '../..');
const scriptUnderTest = join(repoRoot, 'misc/release/pump-version.sh');
const read = (path) => readFileSync(path, 'utf8');
const packageVersion = (dir) => JSON.parse(read(join(dir, 'package.json'))).version;
const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
const writeExecutable = (path, contents) => {
writeFileSync(path, contents, { mode: 0o755 });
};
const writePackageJson = (dir, name, version) => {
writeFileSync(
join(dir, 'package.json'),
`${JSON.stringify({ name, version, private: true }, null, 2)}\n`,
);
};
const makeFixture = (t, { rootVersion = '2.7.5', serverVersion = '3.0.0', mobileBuild = 3047 } = {}) => {
const workdir = mkdtempSync(join(tmpdir(), 'pump-version-'));
t.after(() => rmSync(workdir, { recursive: true, force: true }));
const currentBase = serverVersion.replace(/-rc\..+$/, '');
for (const path of [
'bin',
'server',
'packages/cli',
'web',
'e2e',
'packages/sdk',
'misc/release',
'mobile/android/fastlane',
'mobile/ios/Runner',
'machine-learning',
]) {
mkdirSync(join(workdir, path), { recursive: true });
}
writeCommandStubs(workdir);
writePackageJson(workdir, 'immich-monorepo', rootVersion);
writePackageJson(join(workdir, 'server'), 'immich', serverVersion);
writePackageJson(join(workdir, 'packages/cli'), '@immich/cli', serverVersion);
writePackageJson(join(workdir, 'web'), 'immich-web', serverVersion);
writePackageJson(join(workdir, 'e2e'), 'immich-e2e', serverVersion);
writePackageJson(join(workdir, 'packages/sdk'), '@immich/sdk', serverVersion);
writeExecutable(
join(workdir, 'misc/release/archive-version.js'),
`#!/usr/bin/env bash
set -euo pipefail
echo "$*" >>"$PWD/archive-version.calls"
`,
);
writeFileSync(
join(workdir, 'mobile/pubspec.yaml'),
`name: immich_mobile
version: ${serverVersion}+${mobileBuild}
`,
);
writeFileSync(
join(workdir, 'mobile/android/fastlane/Fastfile'),
`lane :gha_release_prod do
gradle(
properties: {
"android.injected.version.code" => ${mobileBuild},
"android.injected.version.name" => "${serverVersion}",
}
)
end
`,
);
writeFileSync(
join(workdir, 'mobile/ios/Runner/Info.plist'),
`<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>CFBundleShortVersionString</key>
<string>${currentBase}</string>
</dict>
</plist>
`,
);
return {
path: workdir,
file: (path) => join(workdir, path),
readFile: (path) => read(join(workdir, path)),
hasFile: (path) => {
try {
return read(join(workdir, path)).length > 0;
} catch {
return false;
}
},
run: (...args) =>
spawnSync('bash', [scriptUnderTest, ...args], {
cwd: workdir,
env: {
...process.env,
GITHUB_ENV: join(workdir, 'github_env'),
PATH: `${join(workdir, 'bin')}:${process.env.PATH}`,
},
encoding: 'utf8',
}),
};
};
const writeCommandStubs = (workdir) => {
const realNpm = spawnSync('which', ['npm'], { encoding: 'utf8' }).stdout.trim();
writeExecutable(
join(workdir, 'bin/npm'),
`#!/usr/bin/env bash
set -euo pipefail
real_npm=${shellQuote(realNpm)}
echo "$*" >>"$PWD/npm.calls"
"$real_npm" "$@"
`,
);
writeExecutable(
join(workdir, 'bin/pnpm'),
`#!/usr/bin/env bash
set -euo pipefail
if [[ "\${1:-}" != "version" ]]; then
echo "Unexpected pnpm command: $*" >&2
exit 1
fi
shift
version="\${1:-}"
shift
prefix="."
while [[ $# -gt 0 ]]; do
case "$1" in
--prefix)
prefix="$2"
shift 2
;;
--no-git-tag-version)
shift
;;
*)
echo "Unexpected pnpm argument: $1" >&2
exit 1
;;
esac
done
npm --prefix "$prefix" version "$version" --no-git-tag-version --allow-same-version >/dev/null
`,
);
writeExecutable(
join(workdir, 'bin/mise'),
`#!/usr/bin/env bash
set -euo pipefail
echo "$*" >>"$PWD/mise.calls"
`,
);
writeExecutable(
join(workdir, 'bin/uv'),
`#!/usr/bin/env bash
set -euo pipefail
echo "$*" >>"$PWD/uv.calls"
if [[ "\${1:-}" != "version" ]]; then
echo "Unexpected uv command: $*" >&2
exit 1
fi
shift
directory="."
if [[ "\${1:-}" == "--directory" ]]; then
directory="$2"
shift 2
fi
version="\${1:-}"
mkdir -p "$directory"
cat >"$directory/pyproject.toml" <<PYPROJECT
[project]
version = "$version"
PYPROJECT
`,
);
};
const assertCommandPassed = (result) => {
assert.equal(result.status, 0, result.stderr || result.stdout);
};
const assertPackageVersions = (fixture, expected) => {
assert.equal(packageVersion(fixture.path), expected);
assert.equal(packageVersion(fixture.file('server')), expected);
assert.equal(packageVersion(fixture.file('packages/cli')), expected);
assert.equal(packageVersion(fixture.file('web')), expected);
assert.equal(packageVersion(fixture.file('e2e')), expected);
assert.equal(packageVersion(fixture.file('packages/sdk')), expected);
};
const npmCalls = (fixture) => fixture.readFile('npm.calls').trim().split('\n');
test('starts an RC from the server version when the root package is stale', (t) => {
const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.0.0', mobileBuild: 3047 });
const result = fixture.run('-s', 'minor', '-m', 'true', '-r', 'true');
assertCommandPassed(result);
assertPackageVersions(fixture, '3.1.0-rc.0');
assert.ok(npmCalls(fixture).includes('version preminor --preid=rc --no-git-tag-version'));
assert.match(fixture.readFile('mobile/pubspec.yaml'), /version: 3\.1\.0-rc\.0\+3048/);
assert.match(fixture.readFile('mobile/android/fastlane/Fastfile'), /"android\.injected\.version\.name" => "3\.1\.0-rc\.0"/);
assert.match(fixture.readFile('mobile/android/fastlane/Fastfile'), /"android\.injected\.version\.code" => 3048/);
assert.match(fixture.readFile('mobile/ios/Runner/Info.plist'), /<string>3\.1\.0<\/string>/);
assert.match(fixture.readFile('uv.calls'), /version --directory machine-learning 3\.1\.0rc0/);
assert.equal(fixture.hasFile('archive-version.calls'), false);
assert.match(fixture.readFile('github_env'), /IMMICH_VERSION=v3\.1\.0-rc\.0/);
});
test('iterates an existing RC', (t) => {
const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0-rc.0', mobileBuild: 3048 });
const result = fixture.run('-m', 'false', '-r', 'true');
assertCommandPassed(result);
assertPackageVersions(fixture, '3.1.0-rc.1');
assert.ok(npmCalls(fixture).includes('version prerelease --no-git-tag-version'));
assert.equal(npmCalls(fixture).some((call) => call.startsWith('version prerelease --preid')), false);
assert.match(fixture.readFile('mobile/pubspec.yaml'), /version: 3\.1\.0-rc\.1\+3048/);
assert.match(fixture.readFile('uv.calls'), /version --directory machine-learning 3\.1\.0rc1/);
assert.equal(fixture.hasFile('archive-version.calls'), false);
});
test('finalizes an existing RC when rc is false', (t) => {
const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0-rc.1', mobileBuild: 3048 });
const result = fixture.run('-m', 'false', '-r', 'false');
assertCommandPassed(result);
assertPackageVersions(fixture, '3.1.0');
assert.match(fixture.readFile('mobile/pubspec.yaml'), /version: 3\.1\.0\+3048/);
assert.match(fixture.readFile('mobile/ios/Runner/Info.plist'), /<string>3\.1\.0<\/string>/);
assert.match(fixture.readFile('uv.calls'), /version --directory machine-learning 3\.1\.0/);
assert.match(fixture.readFile('archive-version.calls'), /3\.1\.0/);
});
test('bumps a normal patch release', (t) => {
const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0', mobileBuild: 3048 });
const result = fixture.run('-s', 'patch', '-m', 'true');
assertCommandPassed(result);
assertPackageVersions(fixture, '3.1.1');
assert.match(fixture.readFile('mobile/pubspec.yaml'), /version: 3\.1\.1\+3049/);
assert.match(fixture.readFile('uv.calls'), /version --directory machine-learning 3\.1\.1/);
assert.match(fixture.readFile('archive-version.calls'), /3\.1\.1/);
});
test('bumps mobile only', (t) => {
const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0', mobileBuild: 3048 });
const result = fixture.run('-m', 'true');
assertCommandPassed(result);
assert.equal(packageVersion(fixture.path), '2.7.5');
assert.equal(packageVersion(fixture.file('server')), '3.1.0');
assert.match(fixture.readFile('mobile/pubspec.yaml'), /version: 3\.1\.0\+3049/);
assert.equal(fixture.hasFile('uv.calls'), false);
assert.equal(fixture.hasFile('archive-version.calls'), false);
});
test('rejects a server bump while on an RC', (t) => {
const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0-rc.0', mobileBuild: 3048 });
const result = fixture.run('-s', 'patch', '-r', 'true');
assert.notEqual(result.status, 0);
assert.match(result.stdout, /Cannot bump server while on an RC/);
assert.equal(packageVersion(fixture.path), '2.7.5');
assert.equal(packageVersion(fixture.file('server')), '3.1.0-rc.0');
});
test('rejects a server bump while finalizing an RC', (t) => {
const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0-rc.1', mobileBuild: 3048 });
const result = fixture.run('-s', 'patch', '-r', 'false');
assert.notEqual(result.status, 0);
assert.match(result.stdout, /Cannot bump server while on an RC/);
assert.equal(packageVersion(fixture.file('server')), '3.1.0-rc.1');
});
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/utils/option.dart';
class MapConfig {
final int relativeDays;
@@ -6,6 +7,8 @@ class MapConfig {
final bool includeArchived;
final ThemeMode themeMode;
final bool withPartners;
final Option<DateTime> customFrom;
final Option<DateTime> customTo;
const MapConfig({
this.relativeDays = 0,
@@ -13,6 +16,8 @@ class MapConfig {
this.includeArchived = false,
this.themeMode = ThemeMode.system,
this.withPartners = false,
this.customFrom = const Option.none(),
this.customTo = const Option.none(),
});
MapConfig copyWith({
@@ -21,12 +26,16 @@ class MapConfig {
bool? includeArchived,
ThemeMode? themeMode,
bool? withPartners,
Option<DateTime>? customFrom,
Option<DateTime>? customTo,
}) => MapConfig(
relativeDays: relativeDays ?? this.relativeDays,
favoritesOnly: favoritesOnly ?? this.favoritesOnly,
includeArchived: includeArchived ?? this.includeArchived,
themeMode: themeMode ?? this.themeMode,
withPartners: withPartners ?? this.withPartners,
customFrom: customFrom ?? this.customFrom,
customTo: customTo ?? this.customTo,
);
@override
@@ -37,12 +46,15 @@ class MapConfig {
other.favoritesOnly == favoritesOnly &&
other.includeArchived == includeArchived &&
other.themeMode == themeMode &&
other.withPartners == withPartners);
other.withPartners == withPartners &&
other.customFrom == customFrom &&
other.customTo == customTo);
@override
int get hashCode => Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners);
int get hashCode =>
Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners, customFrom, customTo);
@override
String toString() =>
'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners)';
'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners, customFrom: $customFrom, customTo: $customTo)';
}
@@ -50,6 +50,8 @@ enum MetadataKey<T extends Object> {
// Map
mapShowFavoriteOnly<bool>(.appConfig, 'map.showFavoriteOnly', false),
mapRelativeDate<int>(.appConfig, 'map.relativeDate', 0),
mapCustomFrom<String>(.appConfig, 'map.customFrom', '', _DateStringCodec()),
mapCustomTo<String>(.appConfig, 'map.customTo', '', _DateStringCodec()),
mapIncludeArchived<bool>(.appConfig, 'map.includeArchived', false),
mapThemeMode<ThemeMode>(.appConfig, 'map.themeMode', .system, _EnumCodec(ThemeMode.values)),
mapWithPartners<bool>(.appConfig, 'map.withPartners', false),
@@ -164,6 +166,21 @@ final class _ListCodec<T extends Object> extends _MetadataCodec<List<T>> {
}
}
final class _DateStringCodec extends _MetadataCodec<String> {
const _DateStringCodec();
@override
String encode(String value) => value;
@override
String? decode(String raw) {
if (raw.isEmpty) {
return raw;
}
return DateTime.tryParse(raw) != null ? raw : null;
}
}
final class _PrimitiveCodec<T extends Object> extends _MetadataCodec<T> {
final T? Function(String) _parse;
@@ -0,0 +1,15 @@
import 'package:immich_mobile/utils/option.dart';
class TimeRange {
final Option<DateTime> from;
final Option<DateTime> to;
const TimeRange({this.from = const None(), this.to = const None()});
TimeRange copyWith({Option<DateTime>? from, Option<DateTime>? to}) {
return TimeRange(from: from ?? this.from, to: to ?? this.to);
}
TimeRange clearFrom() => TimeRange(to: to);
TimeRange clearTo() => TimeRange(from: from);
}
@@ -27,7 +27,18 @@ class DriftMapRepository extends DriftDatabaseRepository {
condition = condition & _db.remoteAssetEntity.isFavorite.equals(true);
}
if (options.relativeDays != 0) {
final timeRange = options.timeRange;
final hasCustomRange = timeRange.from.isSome || timeRange.to.isSome;
if (hasCustomRange) {
timeRange.from.ifPresent((from) {
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(from);
});
timeRange.to.ifPresent((to) {
condition = condition & _db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(to);
});
} else if (options.relativeDays > 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate);
}
@@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/config/system_config.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/option.dart';
class MetadataRepository extends DriftDatabaseRepository {
final Drift _db;
@@ -97,6 +98,17 @@ class MetadataRepository extends DriftDatabaseRepository {
}
}
Option<DateTime> _parseDateOption(String s) {
if (s.trim().isEmpty) {
return const Option.none();
}
try {
return Option.some(DateTime.parse(s));
} catch (_) {
return const Option.none();
}
}
extension<T extends Object> on MetadataDomain<T> {
T config(MetadataRepository repo) => switch (this) {
.appConfig => repo._appConfig as T,
@@ -126,6 +138,8 @@ extension<T extends Object> on MetadataDomain<T> {
includeArchived: repo._read(.mapIncludeArchived),
themeMode: repo._read(.mapThemeMode),
withPartners: repo._read(.mapWithPartners),
customFrom: _parseDateOption(repo._read(.mapCustomFrom)),
customTo: _parseDateOption(repo._read(.mapCustomTo)),
),
timeline: .new(
tilesPerRow: repo._read(.timelineTilesPerRow),
@@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
@@ -21,6 +22,7 @@ class TimelineMapOptions {
final bool includeArchived;
final bool withPartners;
final int relativeDays;
final TimeRange timeRange;
const TimelineMapOptions({
required this.bounds,
@@ -28,6 +30,7 @@ class TimelineMapOptions {
this.includeArchived = false,
this.withPartners = false,
this.relativeDays = 0,
this.timeRange = const TimeRange(),
});
}
@@ -553,8 +556,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
}
if (options.relativeDays != 0) {
final timeRange = options.timeRange;
final hasCustomRange = timeRange.from.isSome || timeRange.to.isSome;
if (hasCustomRange) {
timeRange.from.ifPresent((from) {
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(from));
});
timeRange.to.ifPresent((to) {
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(to));
});
} else if (options.relativeDays > 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
}
@@ -595,8 +611,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
}
if (options.relativeDays != 0) {
final timeRange = options.timeRange;
final hasCustomRange = timeRange.from.isSome || timeRange.to.isSome;
if (hasCustomRange) {
timeRange.from.ifPresent((from) {
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(from));
});
timeRange.to.ifPresent((to) {
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(to));
});
} else if (options.relativeDays > 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
}
@@ -2,11 +2,13 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/metadata_key.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/providers/infrastructure/map.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class MapState {
@@ -16,6 +18,7 @@ class MapState {
final bool includeArchived;
final bool withPartners;
final int relativeDays;
final TimeRange timeRange;
const MapState({
this.themeMode = ThemeMode.system,
@@ -24,6 +27,7 @@ class MapState {
this.includeArchived = false,
this.withPartners = false,
this.relativeDays = 0,
this.timeRange = const TimeRange(),
});
@override
@@ -41,6 +45,7 @@ class MapState {
bool? includeArchived,
bool? withPartners,
int? relativeDays,
TimeRange? timeRange,
}) {
return MapState(
bounds: bounds ?? this.bounds,
@@ -49,6 +54,7 @@ class MapState {
includeArchived: includeArchived ?? this.includeArchived,
withPartners: withPartners ?? this.withPartners,
relativeDays: relativeDays ?? this.relativeDays,
timeRange: timeRange ?? this.timeRange,
);
}
@@ -58,6 +64,7 @@ class MapState {
includeArchived: includeArchived,
withPartners: withPartners,
relativeDays: relativeDays,
timeRange: timeRange,
);
}
@@ -104,6 +111,28 @@ class MapStateNotifier extends Notifier<MapState> {
EventStream.shared.emit(const MapMarkerReloadEvent());
}
void setTimeRange(TimeRange range) {
final from = range.from.unwrapOrNull;
final to = range.to.unwrapOrNull;
ref.read(metadataProvider).write(MetadataKey.mapCustomFrom, from?.toIso8601String() ?? '');
ref.read(metadataProvider).write(MetadataKey.mapCustomTo, to?.toIso8601String() ?? '');
state = state.copyWith(timeRange: range);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
Option<DateTime> parseDateOption(String s) {
try {
if (s.trim().isEmpty) {
return const Option.none();
}
return Option.some(DateTime.parse(s));
} catch (_) {
return const Option.none();
}
}
@override
MapState build() {
final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
@@ -112,8 +141,9 @@ class MapStateNotifier extends Notifier<MapState> {
onlyFavorites: mapConfig.favoritesOnly,
includeArchived: mapConfig.includeArchived,
withPartners: mapConfig.withPartners,
relativeDays: mapConfig.relativeDays,
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
relativeDays: mapConfig.relativeDays,
timeRange: TimeRange(from: mapConfig.customFrom, to: mapConfig.customTo),
);
}
}
@@ -1,21 +1,39 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_custom_time_range.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
class DriftMapSettingsSheet extends HookConsumerWidget {
class DriftMapSettingsSheet extends ConsumerStatefulWidget {
const DriftMapSettingsSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<DriftMapSettingsSheet> createState() => _DriftMapSettingsSheetState();
}
class _DriftMapSettingsSheetState extends ConsumerState<DriftMapSettingsSheet> {
late bool useCustomRange;
@override
void initState() {
super.initState();
final mapState = ref.read(mapStateProvider);
final timeRange = mapState.timeRange;
useCustomRange = timeRange.from.isSome || timeRange.to.isSome;
}
@override
Widget build(BuildContext context) {
final mapState = ref.watch(mapStateProvider);
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
initialChildSize: useCustomRange ? 0.7 : 0.6,
builder: (ctx, scrollController) => SingleChildScrollView(
controller: scrollController,
child: Card(
@@ -47,10 +65,41 @@ class DriftMapSettingsSheet extends HookConsumerWidget {
selected: mapState.withPartners,
onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners),
),
MapTimeDropDown(
relativeTime: mapState.relativeDays,
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
),
if (useCustomRange) ...[
MapTimeRange(
timeRange: mapState.timeRange,
onChanged: (range) {
ref.read(mapStateProvider.notifier).setTimeRange(range);
},
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => setState(() {
useCustomRange = false;
ref.read(mapStateProvider.notifier).setRelativeTime(0);
ref.read(mapStateProvider.notifier).setTimeRange(const TimeRange());
}),
child: Text(context.t.remove_custom_date_range),
),
),
] else ...[
MapTimeDropDown(
relativeTime: mapState.relativeDays,
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => setState(() {
useCustomRange = true;
ref.read(mapStateProvider.notifier).setRelativeTime(0);
ref.read(mapStateProvider.notifier).setTimeRange(const TimeRange());
}),
child: Text(context.t.use_custom_date_range),
),
),
],
const SizedBox(height: 20),
],
),
+17 -42
View File
@@ -15,62 +15,37 @@ class AuthGuard extends AutoRouteGuard {
final ApiService _apiService;
final AuthService _authService;
final _log = Logger("AuthGuard");
bool _validateInFlight = false;
AuthGuard(this._apiService, this._authService);
@override
void onNavigation(NavigationResolver resolver, StackRouter router) {
// Synchronously check for the access token. auto_route awaits async
// guards, so we keep this function fully sync and validate the token in
// the background — otherwise a slow validateAccessToken() request would
// block the route transition for as long as the OS-level HTTP timeout.
try {
Store.get(StoreKey.accessToken);
} on StoreKeyNotFoundException catch (_) {
_log.warning('No access token in the store.');
resolver.next(false);
unawaited(router.replaceAll([const LoginRoute()]));
return;
}
void onNavigation(NavigationResolver resolver, StackRouter router) async {
resolver.next(true);
unawaited(_validateAccessTokenInBackground(router));
}
Future<void> _validateAccessTokenInBackground(StackRouter router) async {
if (_validateInFlight) {
return;
}
final token = Store.tryGet(StoreKey.accessToken);
if (token == null) {
return;
}
_validateInFlight = true;
try {
// Look in the store for an access token
Store.get(StoreKey.accessToken);
// Validate the access token with the server
final res = await _apiService.authenticationApi.validateAccessToken();
if (res == null || res.authStatus != true) {
// Token may have changed during validation (user logged out + logged in
// again); only act if it still applies to the current session.
if (Store.tryGet(StoreKey.accessToken) != token) {
return;
}
// If the access token is invalid, take user back to login
_log.fine('User token is invalid. Redirecting to login');
await router.replaceAll([const LoginRoute()]);
await _authService.clearLocalData();
unawaited(router.replaceAll([const LoginRoute()]).then((_) => _authService.clearLocalData()));
}
} on StoreKeyNotFoundException catch (_) {
// If there is no access token, take us to the login page
_log.warning('No access token in the store.');
unawaited(router.replaceAll([const LoginRoute()]));
return;
} on ApiException catch (e) {
if (e.code != HttpStatus.unauthorized) {
// On an unauthorized request, take us to the login page
if (e.code == HttpStatus.unauthorized) {
_log.warning("Unauthorized access token.");
unawaited(router.replaceAll([const LoginRoute()]).then((_) => _authService.clearLocalData()));
return;
}
if (Store.tryGet(StoreKey.accessToken) != token) {
return;
}
_log.warning("Unauthorized access token.");
await router.replaceAll([const LoginRoute()]);
await _authService.clearLocalData();
} catch (e) {
// Otherwise, this is not fatal, but we still log the warning
_log.warning('Error validating access token from server: $e');
} finally {
_validateInFlight = false;
}
}
}
+11
View File
@@ -24,6 +24,17 @@ sealed class Option<T> {
None() => onNone(),
};
Option<U> flatMap<U>(Option<U> Function(T value) f) => switch (this) {
Some(:final value) => f(value),
None() => const Option.none(),
};
void ifPresent(void Function(T value) f) {
if (this case Some(:final value)) {
f(value);
}
}
@override
String toString() => switch (this) {
Some(:final value) => 'Some($value)',
@@ -0,0 +1,75 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/time_range.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/utils/option.dart';
class MapTimeRange extends StatelessWidget {
const MapTimeRange({super.key, required this.timeRange, required this.onChanged});
final TimeRange timeRange;
final Function(TimeRange) onChanged;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(context.t.date_after),
subtitle: Text(
timeRange.from.fold(
(from) => DateFormat.yMMMd(context.locale.toLanguageTag()).add_jm().format(from),
() => context.t.not_set,
),
),
trailing: timeRange.from.isSome
? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearFrom()))
: null,
onTap: () async {
final initial = timeRange.from.unwrapOrNull ?? DateTime.now();
final currentTo = timeRange.to.unwrapOrNull;
final picked = await showDatePicker(
context: context,
initialDate: currentTo != null && initial.isAfter(currentTo) ? currentTo : initial,
firstDate: DateTime(1970),
lastDate: currentTo ?? DateTime.now(),
);
if (picked != null) {
onChanged(timeRange.copyWith(from: Option.some(picked)));
}
},
),
ListTile(
title: Text(context.t.date_before),
subtitle: Text(
timeRange.to.fold<String>(
(to) => DateFormat.yMMMd(context.locale.toLanguageTag()).add_jm().format(to),
() => context.t.not_set,
),
),
trailing: timeRange.to.isSome
? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearTo()))
: null,
onTap: () async {
final initial = timeRange.to.unwrapOrNull ?? DateTime.now();
final currentFrom = timeRange.from.unwrapOrNull;
final picked = await showDatePicker(
context: context,
initialDate: currentFrom != null && initial.isBefore(currentFrom) ? currentFrom : initial,
firstDate: currentFrom ?? DateTime(1970),
lastDate: DateTime.now(),
);
if (picked != null) {
onChanged(timeRange.copyWith(to: Option.some(picked)));
}
},
),
],
);
}
}