1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

chore(mobile) Improve mobile UI (#1038)

This commit is contained in:
Alex 2022-11-30 10:58:07 -06:00 committed by GitHub
parent 1068c4ad23
commit d31eddf32f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 214 additions and 65 deletions

View File

@ -17,7 +17,7 @@
"backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_device": "Albums on device ({})",
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
"backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.",
"backup_album_selection_page_select_albums": "Select Albums", "backup_album_selection_page_select_albums": "Select albums",
"backup_album_selection_page_selection_info": "Selection Info", "backup_album_selection_page_selection_info": "Selection Info",
"backup_album_selection_page_total_assets": "Total unique assets", "backup_album_selection_page_total_assets": "Total unique assets",
"backup_all": "All", "backup_all": "All",

View File

@ -21,8 +21,39 @@ class AlbumThumbnailCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
var cardSize = MediaQuery.of(context).size.width / 2 - 18;
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
final cardSize = MediaQuery.of(context).size.width / 2 - 18; buildEmptyThumbnail() {
return Container(
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
),
child: SizedBox(
height: cardSize,
width: cardSize,
child: const Center(
child: Icon(Icons.no_photography),
),
),
);
}
buildAlbumThumbnail() {
return CachedNetworkImage(
memCacheHeight: max(400, cardSize.toInt() * 3),
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 200),
imageUrl: getAlbumThumbnailUrl(
album,
type: ThumbnailFormat.JPEG,
),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
cacheKey: "${album.albumThumbnailAssetId}",
);
}
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
@ -35,19 +66,9 @@ class AlbumThumbnailCard extends StatelessWidget {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage( child: album.albumThumbnailAssetId == null
memCacheHeight: max(400, cardSize.toInt() * 3), ? buildEmptyThumbnail()
width: cardSize, : buildAlbumThumbnail(),
height: cardSize,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 200),
imageUrl:
getAlbumThumbnailUrl(album, type: ThumbnailFormat.JPEG),
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
cacheKey: "${album.albumThumbnailAssetId}",
),
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),

View File

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:flutter/widgets.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
@ -68,6 +69,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final AuthenticationState _authState; final AuthenticationState _authState;
final BackgroundService _backgroundService; final BackgroundService _backgroundService;
final Ref ref; final Ref ref;
var isGettingBackupInfo = false;
/// ///
/// UI INTERACTION /// UI INTERACTION
@ -172,9 +174,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// Get all album on the device /// Get all album on the device
/// Get all selected and excluded album from the user's persistent storage /// Get all selected and excluded album from the user's persistent storage
/// If this is the first time performing backup - set the default selected album to be /// If this is the first time performing backup - set the default selected album to be
/// the one that has all assets (Recent on Android, Recents on iOS) /// the one that has all assets (`Recent` on Android, `Recents` on iOS)
/// ///
Future<void> _getBackupAlbumsInfo() async { Future<void> _getBackupAlbumsInfo() async {
Stopwatch stopwatch = Stopwatch()..start();
// Get all albums on the device // Get all albums on the device
List<AvailableAlbum> availableAlbums = []; List<AvailableAlbum> availableAlbums = [];
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList( List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
@ -182,6 +185,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
type: RequestType.common, type: RequestType.common,
); );
log.info('Found ${albums.length} local albums');
for (AssetPathEntity album in albums) { for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album); AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
@ -293,6 +298,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} catch (e, stackTrace) { } catch (e, stackTrace) {
log.severe("Failed to generate album from id", e, stackTrace); log.severe("Failed to generate album from id", e, stackTrace);
} }
debugPrint("_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms");
} }
/// ///
@ -364,25 +371,29 @@ class BackupNotifier extends StateNotifier<BackUpState> {
return; return;
} }
///
/// Get all necessary information for calculating the available albums, /// Get all necessary information for calculating the available albums,
/// which albums are selected or excluded /// which albums are selected or excluded
/// and then update the UI according to those information /// and then update the UI according to those information
///
Future<void> getBackupInfo() async { Future<void> getBackupInfo() async {
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled(); if (!isGettingBackupInfo) {
isGettingBackupInfo = true;
var isEnabled = await _backgroundService.isBackgroundBackupEnabled();
state = state.copyWith(backgroundBackup: isEnabled); state = state.copyWith(backgroundBackup: isEnabled);
if (state.backupProgress != BackUpProgressEnum.inBackground) { if (state.backupProgress != BackUpProgressEnum.inBackground) {
await _getBackupAlbumsInfo(); await _getBackupAlbumsInfo();
await _updateServerInfo(); await _updateServerInfo();
await _updateBackupAssetCount(); await _updateBackupAssetCount();
} }
isGettingBackupInfo = false;
}
} }
///
/// Save user selection of selected albums and excluded albums to /// Save user selection of selected albums and excluded albums to
/// Hive database /// Hive database
///
void _updatePersistentAlbumsSelection() { void _updatePersistentAlbumsSelection() {
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
Box<HiveBackupAlbums> backupAlbumInfoBox = Box<HiveBackupAlbums> backupAlbumInfoBox =
@ -402,9 +413,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
); );
} }
///
/// Invoke backup process /// Invoke backup process
///
Future<void> startBackupProcess() async { Future<void> startBackupProcess() async {
assert(state.backupProgress == BackUpProgressEnum.idle); assert(state.backupProgress == BackUpProgressEnum.idle);
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress); state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);

View File

@ -36,7 +36,7 @@ class AlbumPreviewPage extends HookConsumerWidget {
title: Column( title: Column(
children: [ children: [
Text( Text(
"${album.name} (${album.assetCountAsync})", album.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
), ),
Padding( Padding(

View File

@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/ui/album_info_card.dart'; import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
@ -14,10 +15,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
const BackupAlbumSelectionPage({Key? key}) : super(key: key); const BackupAlbumSelectionPage({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final availableAlbums = ref.watch(backupProvider).availableAlbums; // final availableAlbums = ref.watch(backupProvider).availableAlbums;
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums; final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums; final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
final isDarkTheme = Theme.of(context).brightness == Brightness.dark; final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
final albums = useState<List<AvailableAlbum>>(
ref.watch(backupProvider).availableAlbums,
);
useEffect( useEffect(
() { () {
@ -28,7 +32,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
); );
buildAlbumSelectionList() { buildAlbumSelectionList() {
if (availableAlbums.isEmpty) { if (albums.value.isEmpty) {
return const Center( return const Center(
child: ImmichLoadingIndicator(), child: ImmichLoadingIndicator(),
); );
@ -38,17 +42,17 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
height: 265, height: 265,
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: availableAlbums.length, itemCount: albums.value.length,
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
itemBuilder: ((context, index) { itemBuilder: ((context, index) {
var thumbnailData = availableAlbums[index].thumbnailData; var thumbnailData = albums.value[index].thumbnailData;
return Padding( return Padding(
padding: index == 0 padding: index == 0
? const EdgeInsets.only(left: 16.00) ? const EdgeInsets.only(left: 16.00)
: const EdgeInsets.all(0), : const EdgeInsets.all(0),
child: AlbumInfoCard( child: AlbumInfoCard(
imageData: thumbnailData, imageData: thumbnailData,
albumInfo: availableAlbums[index], albumInfo: albums.value[index],
), ),
); );
}), }),
@ -79,15 +83,13 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
child: Chip( child: Chip(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(10),
), ),
label: Text( label: Text(
album.name, album.name,
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
color: Theme.of(context).brightness == Brightness.dark color: isDarkTheme ? Colors.black : Colors.white,
? Colors.black
: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@ -119,7 +121,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
child: Chip( child: Chip(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(10),
), ),
label: Text( label: Text(
album.name, album.name,
@ -143,6 +145,46 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
}).toSet(); }).toSet();
} }
buildSearchBar() {
return Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 8.0),
child: TextFormField(
onChanged: (searchValue) {
albums.value = ref
.watch(backupProvider)
.availableAlbums
.where(
(album) => album.name
.toLowerCase()
.contains(searchValue.toLowerCase()),
)
.toList();
},
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 8.0,
),
hintText: "Search",
hintStyle: TextStyle(
color: isDarkTheme ? Colors.white : Colors.grey,
fontSize: 14.0,
),
prefixIcon: const Icon(
Icons.search,
color: Colors.grey,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
filled: true,
fillColor: isDarkTheme ? Colors.white30 : Colors.grey[200],
),
),
);
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
leading: IconButton( leading: IconButton(
@ -188,7 +230,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
child: Card( child: Card(
margin: const EdgeInsets.all(0), margin: const EdgeInsets.all(0),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(10),
side: BorderSide( side: BorderSide(
color: isDarkTheme color: isDarkTheme
? const Color.fromARGB(255, 0, 0, 0) ? const Color.fromARGB(255, 0, 0, 0)
@ -225,8 +267,11 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
ListTile( ListTile(
title: Text( title: Text(
"backup_album_selection_page_albums_device" "backup_album_selection_page_albums_device".tr(
.tr(args: [availableAlbums.length.toString()]), args: [
ref.watch(backupProvider).availableAlbums.length.toString()
],
),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
), ),
subtitle: Padding( subtitle: Padding(
@ -254,7 +299,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
builder: (BuildContext context) { builder: (BuildContext context) {
return AlertDialog( return AlertDialog(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(10),
), ),
elevation: 5, elevation: 5,
title: Text( title: Text(
@ -284,6 +329,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget {
), ),
), ),
buildSearchBar(),
Padding( Padding(
padding: const EdgeInsets.only(bottom: 16.0), padding: const EdgeInsets.only(bottom: 16.0),
child: buildAlbumSelectionList(), child: buildAlbumSelectionList(),

View File

@ -12,6 +12,7 @@ import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/openapi_extensions.dart'; import 'package:immich_mobile/utils/openapi_extensions.dart';
import 'package:immich_mobile/utils/tuple.dart'; import 'package:immich_mobile/utils/tuple.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
final assetServiceProvider = Provider( final assetServiceProvider = Provider(
@ -26,11 +27,13 @@ class AssetService {
final ApiService _apiService; final ApiService _apiService;
final BackupService _backupService; final BackupService _backupService;
final BackgroundService _backgroundService; final BackgroundService _backgroundService;
final log = Logger('AssetService');
AssetService(this._apiService, this._backupService, this._backgroundService); AssetService(this._apiService, this._backupService, this._backgroundService);
/// Returns `null` if the server state did not change, else list of assets /// Returns `null` if the server state did not change, else list of assets
Future<List<Asset>?> getRemoteAssets({required bool hasCache}) async { Future<List<Asset>?> getRemoteAssets({required bool hasCache}) async {
try {
final Box box = Hive.box(userInfoBox); final Box box = Hive.box(userInfoBox);
final Pair<List<AssetResponseDto>, String?>? remote = await _apiService final Pair<List<AssetResponseDto>, String?>? remote = await _apiService
.assetApi .assetApi
@ -40,6 +43,10 @@ class AssetService {
} }
box.put(assetEtagKey, remote.second); box.put(assetEtagKey, remote.second);
return remote.first.map(Asset.remote).toList(growable: false); return remote.first.map(Asset.remote).toList(growable: false);
} catch (e, stack) {
log.severe('Error while getting remote assets', e, stack);
return null;
}
} }
/// if [urgent] is `true`, do not block by waiting on the background service /// if [urgent] is `true`, do not block by waiting on the background service

View File

@ -32,7 +32,9 @@ class ImmichSliverAppBar extends ConsumerWidget {
snap: false, snap: false,
backgroundColor: Theme.of(context).appBarTheme.backgroundColor, backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(5)), borderRadius: BorderRadius.all(
Radius.circular(5),
),
), ),
leading: Builder( leading: Builder(
builder: (BuildContext context) { builder: (BuildContext context) {

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -20,6 +22,7 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/services/share.service.dart'; import 'package:immich_mobile/shared/services/share.service.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@ -37,6 +40,8 @@ class HomePage extends HookConsumerWidget {
final albums = ref.watch(albumProvider); final albums = ref.watch(albumProvider);
final albumService = ref.watch(albumServiceProvider); final albumService = ref.watch(albumServiceProvider);
final tipOneOpacity = useState(0.0);
useEffect( useEffect(
() { () {
ref.read(websocketProvider.notifier).connect(); ref.read(websocketProvider.notifier).connect();
@ -146,6 +151,49 @@ class HomePage extends HookConsumerWidget {
} }
} }
buildLoadingIndicator() {
Timer(const Duration(seconds: 2), () {
tipOneOpacity.value = 1;
});
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const ImmichLoadingIndicator(),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
'Building the timeline',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
color: Theme.of(context).primaryColor,
),
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: tipOneOpacity.value,
child: const SizedBox(
width: 250,
child: Padding(
padding: EdgeInsets.only(top: 8.0),
child: Text(
'If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).',
textAlign: TextAlign.justify,
style: TextStyle(
fontSize: 12,
),
),
),
),
)
],
),
);
}
return SafeArea( return SafeArea(
bottom: !multiselectEnabled.state, bottom: !multiselectEnabled.state,
top: true, top: true,
@ -164,10 +212,12 @@ class HomePage extends HookConsumerWidget {
top: selectionEnabledHook.value ? 0 : 60, top: selectionEnabledHook.value ? 0 : 60,
bottom: 0.0, bottom: 0.0,
), ),
child: ImmichAssetGrid( child: ref.watch(assetProvider).isEmpty
? buildLoadingIndicator()
: ImmichAssetGrid(
renderList: renderList, renderList: renderList,
assetsPerRow: assetsPerRow: appSettingService
appSettingService.getSetting(AppSettingsEnum.tilesPerRow), .getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService showStorageIndicator: appSettingService
.getSetting(AppSettingsEnum.storageIndicator), .getSetting(AppSettingsEnum.storageIndicator),
listener: selectionListener, listener: selectionListener,

View File

@ -83,6 +83,13 @@ class LoginForm extends HookConsumerWidget {
[], [],
); );
populateTestLoginInfo() {
usernameController.text = 'testuser@email.com';
passwordController.text = 'password';
serverEndpointController.text = 'http://10.1.15.216:2283/api';
isSaveLoginInfo.value = true;
}
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300), constraints: const BoxConstraints(maxWidth: 300),
@ -92,11 +99,14 @@ class LoginForm extends HookConsumerWidget {
runSpacing: 16, runSpacing: 16,
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
children: [ children: [
const Image( GestureDetector(
onDoubleTap: () => populateTestLoginInfo(),
child: const Image(
image: AssetImage('assets/immich-logo-no-outline.png'), image: AssetImage('assets/immich-logo-no-outline.png'),
width: 100, width: 100,
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
), ),
),
Text( Text(
'IMMICH', 'IMMICH',
style: TextStyle( style: TextStyle(

View File

@ -15,7 +15,10 @@ class ImmichLoadingIndicator extends StatelessWidget {
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
padding: const EdgeInsets.all(15), padding: const EdgeInsets.all(15),
child: const CircularProgressIndicator(color: Colors.white), child: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
); );
} }
} }