diff --git a/analysis_options.yaml b/analysis_options.yaml
index 0d29021..dd0c4af 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -7,6 +7,12 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
+analyzer:
+ exclude:
+ - build/**
+ - android/**
+ - ios/**
+ - windows/**
include: package:flutter_lints/flutter.yaml
linter:
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 5ffaa39..cb7c742 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -5,7 +5,7 @@ plugins {
}
android {
- namespace = "com.example.nextcloud_client"
+ namespace = "family.nasa.nextcloud_client"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -15,8 +15,7 @@ android {
}
defaultConfig {
- // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
- applicationId = "com.example.nextcloud_client"
+ applicationId = "family.nasa.nextcloud_client"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 799351b..0687e83 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,8 +1,11 @@
+
+ android:icon="@mipmap/ic_launcher"
+ android:networkSecurityConfig="@xml/network_security_config"
+ android:usesCleartextTraffic="true">
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/example/nextcloud_client/MainActivity.kt b/android/app/src/main/kotlin/family/nasa/nextcloud_client/MainActivity.kt
similarity index 71%
rename from android/app/src/main/kotlin/com/example/nextcloud_client/MainActivity.kt
rename to android/app/src/main/kotlin/family/nasa/nextcloud_client/MainActivity.kt
index 63ba9fd..93ead28 100644
--- a/android/app/src/main/kotlin/com/example/nextcloud_client/MainActivity.kt
+++ b/android/app/src/main/kotlin/family/nasa/nextcloud_client/MainActivity.kt
@@ -1,4 +1,4 @@
-package com.example.nextcloud_client
+package family.nasa.nextcloud_client
import io.flutter.embedding.android.FlutterActivity
diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..7438f49
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..927f953
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..5e2e2fb
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..5d57479
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000..1c1ac20
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..c79c58a
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index db77bb4..aebae73 100644
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 17987b7..3fc910f 100644
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 09d4391..b34def6 100644
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index d5f1c8d..0202602 100644
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index 4d6372e..38a0bcf 100644
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..497fb16
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #3D5AD1
+
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..7fc8a21
--- /dev/null
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
diff --git a/assets/icon/app_icon.png b/assets/icon/app_icon.png
new file mode 100644
index 0000000..4dea52a
Binary files /dev/null and b/assets/icon/app_icon.png differ
diff --git a/assets/icon/app_icon_foreground.png b/assets/icon/app_icon_foreground.png
new file mode 100644
index 0000000..d900399
Binary files /dev/null and b/assets/icon/app_icon_foreground.png differ
diff --git a/lib/main.dart b/lib/main.dart
index f9a2f23..150e27a 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,13 +1,13 @@
+import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
-import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart';
import 'providers/server_provider.dart';
import 'theme/app_theme.dart';
-import 'views/account_view.dart';
import 'views/activity_view.dart';
import 'views/files_view.dart';
import 'views/login_view.dart';
import 'views/photos_view.dart';
+import 'views/search_view.dart';
import 'widgets/floating_bottom_bar.dart';
void main() {
@@ -27,13 +27,50 @@ class NextcloudApp extends StatelessWidget {
Widget build(BuildContext context) {
final provider = context.watch();
- return MaterialApp(
- title: 'Nextcloud Material',
- debugShowCheckedModeBanner: false,
- theme: AppTheme.light(provider.seedColor),
- darkTheme: AppTheme.dark(provider.seedColor),
- themeMode: provider.themeMode,
- home: provider.isLoggedIn ? const MainShellView() : const LoginView(),
+ return DynamicColorBuilder(
+ builder: (lightDynamic, darkDynamic) {
+ return MaterialApp(
+ title: 'Noo',
+ debugShowCheckedModeBanner: false,
+ theme: AppTheme.light(
+ provider.seedColor,
+ dynamicScheme: lightDynamic,
+ useDynamicColor: provider.useDynamicColor,
+ ),
+ darkTheme: AppTheme.dark(
+ provider.seedColor,
+ dynamicScheme: darkDynamic,
+ useDynamicColor: provider.useDynamicColor,
+ ),
+ themeMode: provider.themeMode,
+ home: provider.isRestoringSession
+ ? const _SplashView()
+ : (provider.isLoggedIn
+ ? const MainShellView()
+ : const LoginView()),
+ );
+ },
+ );
+ }
+}
+
+class _SplashView extends StatelessWidget {
+ const _SplashView();
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return Scaffold(
+ body: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.cloud_outlined, size: 48, color: colorScheme.primary),
+ const SizedBox(height: 20),
+ CircularProgressIndicator(color: colorScheme.primary),
+ ],
+ ),
+ ),
);
}
}
@@ -47,32 +84,13 @@ class MainShellView extends StatefulWidget {
class _MainShellViewState extends State {
int _currentIndex = 0;
- bool _isNavVisible = true;
late final List _scrollControllers;
@override
void initState() {
super.initState();
- _scrollControllers = List.generate(4, (index) {
- final controller = ScrollController();
- controller.addListener(() {
- if (controller.position.userScrollDirection == ScrollDirection.reverse) {
- if (_isNavVisible) {
- setState(() {
- _isNavVisible = false;
- });
- }
- } else if (controller.position.userScrollDirection == ScrollDirection.forward) {
- if (!_isNavVisible) {
- setState(() {
- _isNavVisible = true;
- });
- }
- }
- });
- return controller;
- });
+ _scrollControllers = List.generate(3, (index) => ScrollController());
}
@override
@@ -85,27 +103,11 @@ class _MainShellViewState extends State {
@override
Widget build(BuildContext context) {
+ final provider = context.watch();
final navItems = const [
- FloatingNavItem(
- label: 'Files',
- icon: Icons.folder_outlined,
- selectedIcon: Icons.folder_rounded,
- ),
- FloatingNavItem(
- label: 'Photos',
- icon: Icons.photo_library_outlined,
- selectedIcon: Icons.photo_library_rounded,
- ),
- FloatingNavItem(
- label: 'Activity',
- icon: Icons.history_rounded,
- selectedIcon: Icons.history_toggle_off_rounded,
- ),
- FloatingNavItem(
- label: 'Account',
- icon: Icons.account_circle_outlined,
- selectedIcon: Icons.account_circle_rounded,
- ),
+ FloatingNavItem(label: 'Files', icon: Icons.folder_rounded),
+ FloatingNavItem(label: 'Photos', icon: Icons.photo_library_rounded),
+ FloatingNavItem(label: 'Activity', icon: Icons.history_rounded),
];
return Scaffold(
@@ -117,19 +119,23 @@ class _MainShellViewState extends State {
FilesView(scrollController: _scrollControllers[0]),
PhotosView(scrollController: _scrollControllers[1]),
ActivityView(scrollController: _scrollControllers[2]),
- AccountView(scrollController: _scrollControllers[3]),
],
),
FloatingBottomNavBar(
selectedIndex: _currentIndex,
items: navItems,
- isVisible: _isNavVisible,
+ opacity: provider.bottomBarOpacity,
+ blurSigma: provider.bottomBarBlur,
onDestinationSelected: (index) {
setState(() {
_currentIndex = index;
- _isNavVisible = true;
});
},
+ onSearchTap: () {
+ Navigator.of(
+ context,
+ ).push(MaterialPageRoute(builder: (_) => const SearchView()));
+ },
),
],
),
diff --git a/lib/models/nextcloud_item.dart b/lib/models/nextcloud_item.dart
index e1abdc4..0fedcf0 100644
--- a/lib/models/nextcloud_item.dart
+++ b/lib/models/nextcloud_item.dart
@@ -9,12 +9,14 @@ class NextcloudItem {
final NextcloudItemType type;
final int size; // in bytes
final DateTime lastModified;
+ final DateTime dateCreated;
final bool isFavorite;
final String? mimeType;
final String? etag;
final String? previewUrl;
final int? imageWidth;
final int? imageHeight;
+ final String? mountType;
const NextcloudItem({
required this.id,
@@ -23,16 +25,20 @@ class NextcloudItem {
required this.type,
required this.size,
required this.lastModified,
+ DateTime? dateCreated,
this.isFavorite = false,
this.mimeType,
this.etag,
this.previewUrl,
this.imageWidth,
this.imageHeight,
- });
+ this.mountType,
+ }) : dateCreated = dateCreated ?? lastModified;
bool get isFolder => type == NextcloudItemType.folder;
- bool get isMedia => type == NextcloudItemType.image || type == NextcloudItemType.video;
+ bool get isMedia =>
+ type == NextcloudItemType.image || type == NextcloudItemType.video;
+ bool get isExternalStorage => mountType == 'external';
NextcloudItem copyWith({
String? id,
@@ -41,12 +47,14 @@ class NextcloudItem {
NextcloudItemType? type,
int? size,
DateTime? lastModified,
+ DateTime? dateCreated,
bool? isFavorite,
String? mimeType,
String? etag,
String? previewUrl,
int? imageWidth,
int? imageHeight,
+ String? mountType,
}) {
return NextcloudItem(
id: id ?? this.id,
@@ -55,31 +63,58 @@ class NextcloudItem {
type: type ?? this.type,
size: size ?? this.size,
lastModified: lastModified ?? this.lastModified,
+ dateCreated: dateCreated ?? this.dateCreated,
isFavorite: isFavorite ?? this.isFavorite,
mimeType: mimeType ?? this.mimeType,
etag: etag ?? this.etag,
previewUrl: previewUrl ?? this.previewUrl,
imageWidth: imageWidth ?? this.imageWidth,
imageHeight: imageHeight ?? this.imageHeight,
+ mountType: mountType ?? this.mountType,
);
}
- static NextcloudItemType deduceType(String path, bool isCollection, String? mime) {
+ static NextcloudItemType deduceType(
+ String path,
+ bool isCollection,
+ String? mime,
+ ) {
if (isCollection) return NextcloudItemType.folder;
final lower = path.toLowerCase();
- if (mime?.startsWith('image/') == true || lower.endsWith('.jpg') || lower.endsWith('.jpeg') || lower.endsWith('.png') || lower.endsWith('.webp') || lower.endsWith('.gif') || lower.endsWith('.heic')) {
+ if (mime?.startsWith('image/') == true ||
+ lower.endsWith('.jpg') ||
+ lower.endsWith('.jpeg') ||
+ lower.endsWith('.png') ||
+ lower.endsWith('.webp') ||
+ lower.endsWith('.gif') ||
+ lower.endsWith('.heic')) {
return NextcloudItemType.image;
}
- if (mime?.startsWith('video/') == true || lower.endsWith('.mp4') || lower.endsWith('.mov') || lower.endsWith('.mkv') || lower.endsWith('.webm')) {
+ if (mime?.startsWith('video/') == true ||
+ lower.endsWith('.mp4') ||
+ lower.endsWith('.mov') ||
+ lower.endsWith('.mkv') ||
+ lower.endsWith('.webm')) {
return NextcloudItemType.video;
}
- if (mime?.startsWith('audio/') == true || lower.endsWith('.mp3') || lower.endsWith('.flac') || lower.endsWith('.wav') || lower.endsWith('.ogg')) {
+ if (mime?.startsWith('audio/') == true ||
+ lower.endsWith('.mp3') ||
+ lower.endsWith('.flac') ||
+ lower.endsWith('.wav') ||
+ lower.endsWith('.ogg')) {
return NextcloudItemType.audio;
}
- if (lower.endsWith('.pdf') || lower.endsWith('.txt') || lower.endsWith('.md') || lower.endsWith('.docx') || lower.endsWith('.xlsx')) {
+ if (lower.endsWith('.pdf') ||
+ lower.endsWith('.txt') ||
+ lower.endsWith('.md') ||
+ lower.endsWith('.docx') ||
+ lower.endsWith('.xlsx')) {
return NextcloudItemType.document;
}
- if (lower.endsWith('.zip') || lower.endsWith('.tar') || lower.endsWith('.gz') || lower.endsWith('.7z')) {
+ if (lower.endsWith('.zip') ||
+ lower.endsWith('.tar') ||
+ lower.endsWith('.gz') ||
+ lower.endsWith('.7z')) {
return NextcloudItemType.archive;
}
return NextcloudItemType.file;
diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart
index 2d74d24..4ed9a71 100644
--- a/lib/providers/server_provider.dart
+++ b/lib/providers/server_provider.dart
@@ -1,29 +1,71 @@
-import 'dart:convert';
-import 'dart:io';
-import 'package:flutter/foundation.dart';
+import 'dart:async';
import 'package:flutter/material.dart';
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:url_launcher/url_launcher.dart';
import '../models/nextcloud_item.dart';
+import '../services/login_flow_service.dart';
import '../services/nextcloud_service.dart';
import '../theme/app_theme.dart';
+enum LoginFlowStatus { idle, initiating, awaitingBrowser, error }
+
+enum FileSortField { name, dateCreated, dateModified, size }
+
class ServerProvider extends ChangeNotifier {
+ static const _storage = FlutterSecureStorage();
+ static const _keyServerUrl = 'nc_server_url';
+ static const _keyLoginName = 'nc_login_name';
+ static const _keyAppPassword = 'nc_app_password';
+
+ // Cached UI settings/toggles (SharedPreferences keys)
+ static const _prefThemeMode = 'ui_theme_mode';
+ static const _prefUseDynamicColor = 'ui_use_dynamic_color';
+ static const _prefSeedColor = 'ui_seed_color';
+ static const _prefBottomBarOpacity = 'ui_bottom_bar_opacity';
+ static const _prefBottomBarBlur = 'ui_bottom_bar_blur';
+ static const _prefGridView = 'ui_grid_view';
+ static const _prefShowFavoritesOnly = 'ui_show_favorites_only';
+ static const _prefShowExternalOnly = 'ui_show_external_only';
+ static const _prefShowHidden = 'ui_show_hidden';
+ static const _prefSortField = 'ui_sort_field';
+ static const _prefSortAscending = 'ui_sort_ascending';
+
+ final Future _prefsFuture =
+ SharedPreferences.getInstance();
+
String _serverUrl = '';
String _username = '';
String _password = '';
bool _isLoggedIn = false;
bool _isLoading = false;
+ bool _isRestoringSession = true;
String? _errorMessage;
+ // Login flow v2 state
+ LoginFlowStatus _loginFlowStatus = LoginFlowStatus.idle;
+ Uri? _pendingLoginUrl;
+ Timer? _pollTimer;
+ Timer? _pollTimeoutTimer;
+
// Theme state
Color _seedColor = AppTheme.defaultNextcloudBlue;
ThemeMode _themeMode = ThemeMode.system;
+ bool _useDynamicColor = true;
+
+ // UI settings
+ double _bottomBarOpacity = 0.55;
+ double _bottomBarBlur = 28;
// Navigation state
String _currentFolderPath = '/';
List _pathStack = ['/'];
bool _isGridView = false;
- String _searchQuery = '';
bool _showFavoritesOnly = false;
+ bool _showExternalOnly = false;
+ bool _showHidden = false;
+ FileSortField _sortField = FileSortField.name;
+ bool _sortAscending = true;
// Data state
List _items = [];
@@ -33,95 +75,158 @@ class ServerProvider extends ChangeNotifier {
NextcloudService? _service;
ServerProvider() {
- _loadSavedSession();
+ _restoreSession();
+ _loadPreferences();
}
// Getters
String get serverUrl => _serverUrl;
String get username => _username;
- String get password => _password;
bool get isLoggedIn => _isLoggedIn;
bool get isLoading => _isLoading;
+ bool get isRestoringSession => _isRestoringSession;
String? get errorMessage => _errorMessage;
+ LoginFlowStatus get loginFlowStatus => _loginFlowStatus;
+ Uri? get pendingLoginUrl => _pendingLoginUrl;
+
Color get seedColor => _seedColor;
ThemeMode get themeMode => _themeMode;
+ bool get useDynamicColor => _useDynamicColor;
+ double get bottomBarOpacity => _bottomBarOpacity;
+ double get bottomBarBlur => _bottomBarBlur;
String get currentFolderPath => _currentFolderPath;
List get pathStack => _pathStack;
bool get isGridView => _isGridView;
- String get searchQuery => _searchQuery;
bool get showFavoritesOnly => _showFavoritesOnly;
+ bool get showExternalOnly => _showExternalOnly;
+ bool get showHidden => _showHidden;
+ FileSortField get sortField => _sortField;
+ bool get sortAscending => _sortAscending;
+
+ /// Exposes the active service so views can build file URLs, auth headers,
+ /// and trigger downloads/previews directly.
+ NextcloudService? get service => _service;
List get items {
var filtered = _items;
if (_showFavoritesOnly) {
filtered = filtered.where((item) => item.isFavorite).toList();
}
- if (_searchQuery.trim().isNotEmpty) {
- final query = _searchQuery.toLowerCase();
- filtered = filtered.where((item) => item.name.toLowerCase().contains(query)).toList();
+ if (_showExternalOnly) {
+ filtered = filtered.where((item) => item.isExternalStorage).toList();
}
- return filtered;
+ if (!_showHidden) {
+ filtered = filtered.where((item) => !item.name.startsWith('.')).toList();
+ }
+
+ int compare(NextcloudItem a, NextcloudItem b) {
+ switch (_sortField) {
+ case FileSortField.name:
+ return a.name.toLowerCase().compareTo(b.name.toLowerCase());
+ case FileSortField.dateCreated:
+ return a.dateCreated.compareTo(b.dateCreated);
+ case FileSortField.dateModified:
+ return a.lastModified.compareTo(b.lastModified);
+ case FileSortField.size:
+ return a.size.compareTo(b.size);
+ }
+ }
+
+ final folders = filtered.where((i) => i.isFolder).toList()..sort(compare);
+ final files = filtered.where((i) => !i.isFolder).toList()..sort(compare);
+ return _sortAscending
+ ? [...folders, ...files]
+ : [...folders.reversed, ...files.reversed];
}
List get photoItems {
- final allMedia = _items.where((i) => i.isMedia || i.type == NextcloudItemType.image).toList();
- if (_searchQuery.trim().isNotEmpty) {
- final query = _searchQuery.toLowerCase();
- return allMedia.where((i) => i.name.toLowerCase().contains(query)).toList();
- }
- return allMedia;
+ return _items
+ .where((i) => i.isMedia || i.type == NextcloudItemType.image)
+ .toList();
}
NextcloudUserQuota? get quota => _quota;
List get activities => _activities;
- Future _loadSavedSession() async {
+ Future _restoreSession() async {
try {
- final file = File('.nextcloud_session.json');
- if (await file.exists()) {
- final content = await file.readAsString();
- final data = jsonDecode(content);
- if (data['serverUrl'] != null && data['username'] != null && data['password'] != null) {
- final sUrl = data['serverUrl'].toString();
- final uName = data['username'].toString();
- final pWord = data['password'].toString();
+ final server = await _storage.read(key: _keyServerUrl);
+ final loginName = await _storage.read(key: _keyLoginName);
+ final appPassword = await _storage.read(key: _keyAppPassword);
- if (sUrl.isNotEmpty && uName.isNotEmpty && pWord.isNotEmpty) {
- await login(serverUrl: sUrl, username: uName, password: pWord, saveSession: false);
- }
- }
+ if (server != null && loginName != null && appPassword != null) {
+ await _applyCredentials(server, loginName, appPassword, persist: false);
}
- } catch (_) {}
+ } catch (e) {
+ debugPrint('[ServerProvider] Session restore failed: $e');
+ } finally {
+ _isRestoringSession = false;
+ notifyListeners();
+ }
}
- Future _saveSession() async {
+ Future _loadPreferences() async {
try {
- final file = File('.nextcloud_session.json');
- final data = {
- 'serverUrl': _serverUrl,
- 'username': _username,
- 'password': _password,
- };
- await file.writeAsString(jsonEncode(data));
- } catch (_) {}
- }
+ final prefs = await _prefsFuture;
- Future _clearSavedSession() async {
- try {
- final file = File('.nextcloud_session.json');
- if (await file.exists()) {
- await file.delete();
+ final themeModeName = prefs.getString(_prefThemeMode);
+ if (themeModeName != null) {
+ _themeMode = ThemeMode.values.firstWhere(
+ (m) => m.name == themeModeName,
+ orElse: () => ThemeMode.system,
+ );
}
- } catch (_) {}
+ _useDynamicColor =
+ prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor;
+ final seedColorValue = prefs.getInt(_prefSeedColor);
+ if (seedColorValue != null) _seedColor = Color(seedColorValue);
+ _bottomBarOpacity =
+ prefs.getDouble(_prefBottomBarOpacity) ?? _bottomBarOpacity;
+ _bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur;
+ _isGridView = prefs.getBool(_prefGridView) ?? _isGridView;
+ _showFavoritesOnly =
+ prefs.getBool(_prefShowFavoritesOnly) ?? _showFavoritesOnly;
+ _showExternalOnly =
+ prefs.getBool(_prefShowExternalOnly) ?? _showExternalOnly;
+ _showHidden = prefs.getBool(_prefShowHidden) ?? _showHidden;
+ final sortFieldName = prefs.getString(_prefSortField);
+ if (sortFieldName != null) {
+ _sortField = FileSortField.values.firstWhere(
+ (f) => f.name == sortFieldName,
+ orElse: () => FileSortField.name,
+ );
+ }
+ _sortAscending = prefs.getBool(_prefSortAscending) ?? _sortAscending;
+
+ notifyListeners();
+ } catch (e) {
+ debugPrint('[ServerProvider] Preference restore failed: $e');
+ }
}
- Future login({
- required String serverUrl,
- required String username,
- required String password,
- bool saveSession = true,
+ Future _persistCredentials(
+ String server,
+ String loginName,
+ String appPassword,
+ ) async {
+ await _storage.write(key: _keyServerUrl, value: server);
+ await _storage.write(key: _keyLoginName, value: loginName);
+ await _storage.write(key: _keyAppPassword, value: appPassword);
+ }
+
+ Future _clearPersistedCredentials() async {
+ await _storage.delete(key: _keyServerUrl);
+ await _storage.delete(key: _keyLoginName);
+ await _storage.delete(key: _keyAppPassword);
+ }
+
+ Future _applyCredentials(
+ String serverUrl,
+ String username,
+ String appPassword, {
+ bool persist = true,
}) async {
_isLoading = true;
_errorMessage = null;
@@ -129,7 +234,7 @@ class ServerProvider extends ChangeNotifier {
_serverUrl = serverUrl;
_username = username;
- _password = password;
+ _password = appPassword;
_service = NextcloudService(
serverUrl: _serverUrl,
@@ -143,8 +248,8 @@ class ServerProvider extends ChangeNotifier {
_isLoggedIn = true;
_currentFolderPath = '/';
_pathStack = ['/'];
- if (saveSession) {
- await _saveSession();
+ if (persist) {
+ await _persistCredentials(_serverUrl, _username, _password);
}
await refreshData();
_isLoading = false;
@@ -153,14 +258,101 @@ class ServerProvider extends ChangeNotifier {
}
} catch (e) {
_errorMessage = e.toString().replaceAll('Exception: ', '');
+ if (!persist) {
+ // A previously-saved app password no longer works; drop it.
+ await _clearPersistedCredentials();
+ }
}
_isLoggedIn = false;
+ _service = null;
_isLoading = false;
notifyListeners();
return false;
}
+ /// Starts Nextcloud Login Flow v2: asks the server for a one-time login
+ /// URL, opens it in the system browser, then polls until the user
+ /// authorizes and the server hands back a scoped app password. The app
+ /// never sees the user's real password.
+ Future startLoginFlow(String serverUrl) async {
+ _loginFlowStatus = LoginFlowStatus.initiating;
+ _errorMessage = null;
+ notifyListeners();
+
+ try {
+ final init = await LoginFlowService.initiate(serverUrl);
+ _pendingLoginUrl = init.loginUrl;
+
+ final opened = await launchUrl(
+ init.loginUrl,
+ mode: LaunchMode.externalApplication,
+ );
+ if (!opened) {
+ throw Exception('Could not open the browser for login.');
+ }
+
+ _loginFlowStatus = LoginFlowStatus.awaitingBrowser;
+ notifyListeners();
+
+ _pollTimer?.cancel();
+ _pollTimeoutTimer?.cancel();
+
+ _pollTimeoutTimer = Timer(const Duration(minutes: 10), () {
+ if (_loginFlowStatus == LoginFlowStatus.awaitingBrowser) {
+ cancelLoginFlow(errorMessage: 'Login timed out. Please try again.');
+ }
+ });
+
+ _pollTimer = Timer.periodic(const Duration(seconds: 2), (timer) async {
+ try {
+ final result = await LoginFlowService.poll(
+ init.pollEndpoint,
+ init.pollToken,
+ );
+ if (result != null) {
+ timer.cancel();
+ _pollTimeoutTimer?.cancel();
+ _loginFlowStatus = LoginFlowStatus.idle;
+ _pendingLoginUrl = null;
+ await _applyCredentials(
+ result.serverUrl,
+ result.loginName,
+ result.appPassword,
+ );
+ }
+ } catch (e) {
+ timer.cancel();
+ _pollTimeoutTimer?.cancel();
+ cancelLoginFlow(
+ errorMessage: e.toString().replaceAll('Exception: ', ''),
+ );
+ }
+ });
+ } catch (e) {
+ _loginFlowStatus = LoginFlowStatus.error;
+ _errorMessage = e.toString().replaceAll('Exception: ', '');
+ notifyListeners();
+ }
+ }
+
+ Future reopenLoginBrowser() async {
+ if (_pendingLoginUrl != null) {
+ await launchUrl(_pendingLoginUrl!, mode: LaunchMode.externalApplication);
+ }
+ }
+
+ void cancelLoginFlow({String? errorMessage}) {
+ _pollTimer?.cancel();
+ _pollTimeoutTimer?.cancel();
+ _pendingLoginUrl = null;
+ _loginFlowStatus = errorMessage != null
+ ? LoginFlowStatus.error
+ : LoginFlowStatus.idle;
+ _errorMessage = errorMessage;
+ notifyListeners();
+ }
+
Future logout() async {
_isLoggedIn = false;
_serverUrl = '';
@@ -170,13 +362,15 @@ class ServerProvider extends ChangeNotifier {
_quota = null;
_activities = [];
_service = null;
- await _clearSavedSession();
+ await _clearPersistedCredentials();
notifyListeners();
}
Future refreshData() async {
if (!_isLoggedIn || _service == null) {
- debugPrint('[ServerProvider] refreshData skipped: isLoggedIn=$_isLoggedIn, service=${_service != null}');
+ debugPrint(
+ '[ServerProvider] refreshData skipped: isLoggedIn=$_isLoggedIn, service=${_service != null}',
+ );
return;
}
@@ -184,20 +378,28 @@ class ServerProvider extends ChangeNotifier {
_errorMessage = null;
notifyListeners();
- debugPrint('[ServerProvider] Refreshing data for path: $_currentFolderPath');
+ debugPrint(
+ '[ServerProvider] Refreshing data for path: $_currentFolderPath',
+ );
try {
_items = await _service!.fetchDirectory(_currentFolderPath);
- debugPrint('[ServerProvider] Loaded ${_items.length} items for $_currentFolderPath');
+ debugPrint(
+ '[ServerProvider] Loaded ${_items.length} items for $_currentFolderPath',
+ );
try {
_quota = await _service!.fetchUserQuota();
- debugPrint('[ServerProvider] Loaded quota: used=${_quota?.usedBytes}, total=${_quota?.totalBytes}');
+ debugPrint(
+ '[ServerProvider] Loaded quota: used=${_quota?.usedBytes}, total=${_quota?.totalBytes}',
+ );
} catch (e) {
debugPrint('[ServerProvider] Quota fetch warning: $e');
}
try {
_activities = await _service!.fetchActivities();
- debugPrint('[ServerProvider] Loaded ${_activities.length} activity items');
+ debugPrint(
+ '[ServerProvider] Loaded ${_activities.length} activity items',
+ );
} catch (e) {
debugPrint('[ServerProvider] Activity fetch warning: $e');
}
@@ -210,6 +412,11 @@ class ServerProvider extends ChangeNotifier {
}
}
+ Future> searchFiles(String query) async {
+ if (_service == null) return [];
+ return _service!.searchFiles(query);
+ }
+
Future navigateToFolder(String path) async {
_currentFolderPath = path;
_pathStack.add(path);
@@ -224,24 +431,69 @@ class ServerProvider extends ChangeNotifier {
}
}
- void toggleViewMode() {
- _isGridView = !_isGridView;
- notifyListeners();
+ /// Jumps directly to an ancestor folder by its position in [pathStack]
+ /// (as tapped from a breadcrumb), trimming everything below it.
+ Future navigateToPathIndex(int index) async {
+ if (index < 0 || index >= _pathStack.length - 1) return;
+ _pathStack = _pathStack.sublist(0, index + 1);
+ _currentFolderPath = _pathStack.last;
+ await refreshData();
}
- void setSearchQuery(String query) {
- _searchQuery = query;
+ void setGridView(bool value) {
+ if (_isGridView == value) return;
+ _isGridView = value;
notifyListeners();
+ _prefsFuture.then((p) => p.setBool(_prefGridView, value));
}
void toggleFavoritesFilter() {
_showFavoritesOnly = !_showFavoritesOnly;
notifyListeners();
+ _prefsFuture.then(
+ (p) => p.setBool(_prefShowFavoritesOnly, _showFavoritesOnly),
+ );
}
- Future uploadFile(String name, Uint8List bytes) async {
+ void toggleExternalStorageFilter() {
+ _showExternalOnly = !_showExternalOnly;
+ notifyListeners();
+ _prefsFuture.then(
+ (p) => p.setBool(_prefShowExternalOnly, _showExternalOnly),
+ );
+ }
+
+ void toggleShowHidden() {
+ _showHidden = !_showHidden;
+ notifyListeners();
+ _prefsFuture.then((p) => p.setBool(_prefShowHidden, _showHidden));
+ }
+
+ void setSortField(FileSortField field) {
+ if (_sortField == field) return;
+ _sortField = field;
+ notifyListeners();
+ _prefsFuture.then((p) => p.setString(_prefSortField, field.name));
+ }
+
+ void toggleSortOrder() {
+ _sortAscending = !_sortAscending;
+ notifyListeners();
+ _prefsFuture.then((p) => p.setBool(_prefSortAscending, _sortAscending));
+ }
+
+ Future uploadFileFromPath(
+ String name,
+ String localFilePath, {
+ void Function(int sent, int total)? onProgress,
+ }) async {
if (_service == null) return false;
- final success = await _service!.uploadFile(_currentFolderPath, name, bytes);
+ final success = await _service!.uploadFileFromPath(
+ _currentFolderPath,
+ name,
+ localFilePath,
+ onProgress: onProgress,
+ );
if (success) {
await refreshData();
}
@@ -259,7 +511,10 @@ class ServerProvider extends ChangeNotifier {
Future createFolder(String folderName) async {
if (_service == null) return false;
- final success = await _service!.createFolder(_currentFolderPath, folderName);
+ final success = await _service!.createFolder(
+ _currentFolderPath,
+ folderName,
+ );
if (success) {
await refreshData();
}
@@ -280,11 +535,42 @@ class ServerProvider extends ChangeNotifier {
void setSeedColor(Color color) {
_seedColor = color;
+ _useDynamicColor = false;
notifyListeners();
+ _prefsFuture.then((p) {
+ p.setInt(_prefSeedColor, color.toARGB32());
+ p.setBool(_prefUseDynamicColor, false);
+ });
+ }
+
+ void setUseDynamicColor(bool value) {
+ _useDynamicColor = value;
+ notifyListeners();
+ _prefsFuture.then((p) => p.setBool(_prefUseDynamicColor, value));
+ }
+
+ void setBottomBarOpacity(double value) {
+ _bottomBarOpacity = value;
+ notifyListeners();
+ _prefsFuture.then((p) => p.setDouble(_prefBottomBarOpacity, value));
+ }
+
+ void setBottomBarBlur(double value) {
+ _bottomBarBlur = value;
+ notifyListeners();
+ _prefsFuture.then((p) => p.setDouble(_prefBottomBarBlur, value));
}
void setThemeMode(ThemeMode mode) {
_themeMode = mode;
notifyListeners();
+ _prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name));
+ }
+
+ @override
+ void dispose() {
+ _pollTimer?.cancel();
+ _pollTimeoutTimer?.cancel();
+ super.dispose();
}
}
diff --git a/lib/services/login_flow_service.dart b/lib/services/login_flow_service.dart
new file mode 100644
index 0000000..0c2dc1a
--- /dev/null
+++ b/lib/services/login_flow_service.dart
@@ -0,0 +1,117 @@
+import 'dart:convert';
+import 'package:flutter/foundation.dart';
+import 'package:http/http.dart' as http;
+
+class LoginFlowInit {
+ final Uri loginUrl;
+ final Uri pollEndpoint;
+ final String pollToken;
+
+ const LoginFlowInit({
+ required this.loginUrl,
+ required this.pollEndpoint,
+ required this.pollToken,
+ });
+}
+
+class LoginFlowResult {
+ final String serverUrl;
+ final String loginName;
+ final String appPassword;
+
+ const LoginFlowResult({
+ required this.serverUrl,
+ required this.loginName,
+ required this.appPassword,
+ });
+}
+
+/// Implements Nextcloud's "Login Flow v2": the app never sees the user's
+/// real password. It asks the server for a one-time login URL, the user
+/// authorizes in their browser, and the app polls until the server hands
+/// back a scoped app password.
+/// https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
+class LoginFlowService {
+ static const _userAgent = 'Nextcloud-Flutter-Client/1.0';
+
+ static String normalizeServerUrl(String input) {
+ var url = input.trim();
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
+ url = 'https://$url';
+ }
+ if (url.endsWith('/')) {
+ url = url.substring(0, url.length - 1);
+ }
+ return url;
+ }
+
+ static Future initiate(String serverUrl) async {
+ final cleanUrl = normalizeServerUrl(serverUrl);
+ final endpoint = Uri.parse('$cleanUrl/index.php/login/v2');
+ debugPrint('[LoginFlow] Initiating login flow at $endpoint');
+
+ final response = await http.post(
+ endpoint,
+ headers: {'User-Agent': _userAgent, 'OCS-APIRequest': 'true'},
+ );
+
+ if (response.statusCode != 200) {
+ throw Exception(
+ 'Could not start login flow (HTTP ${response.statusCode}). '
+ 'Check the server address and that it is a reachable Nextcloud instance.',
+ );
+ }
+
+ final data = jsonDecode(response.body);
+ final loginUrl = data['login'] as String?;
+ final poll = data['poll'] as Map?;
+ final pollToken = poll?['token'] as String?;
+ final pollEndpoint = poll?['endpoint'] as String?;
+
+ if (loginUrl == null || pollToken == null || pollEndpoint == null) {
+ throw Exception('Server response did not include login flow details.');
+ }
+
+ return LoginFlowInit(
+ loginUrl: Uri.parse(loginUrl),
+ pollEndpoint: Uri.parse(pollEndpoint),
+ pollToken: pollToken,
+ );
+ }
+
+ /// Performs a single poll attempt. Returns null while the user has not
+ /// finished authorizing yet (server responds 404 during that window).
+ static Future poll(Uri pollEndpoint, String token) async {
+ final response = await http.post(
+ pollEndpoint,
+ headers: {
+ 'User-Agent': _userAgent,
+ 'OCS-APIRequest': 'true',
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: {'token': token},
+ );
+
+ if (response.statusCode == 404) {
+ return null;
+ }
+ if (response.statusCode != 200) {
+ throw Exception('Login poll failed (HTTP ${response.statusCode}).');
+ }
+
+ final data = jsonDecode(response.body);
+ final server = data['server'] as String?;
+ final loginName = data['loginName'] as String?;
+ final appPassword = data['appPassword'] as String?;
+
+ if (server == null || loginName == null || appPassword == null) {
+ throw Exception('Server response did not include app credentials.');
+ }
+
+ return LoginFlowResult(
+ serverUrl: server,
+ loginName: loginName,
+ appPassword: appPassword,
+ );
+ }
+}
diff --git a/lib/services/nextcloud_service.dart b/lib/services/nextcloud_service.dart
index af48d8f..0165458 100644
--- a/lib/services/nextcloud_service.dart
+++ b/lib/services/nextcloud_service.dart
@@ -1,5 +1,6 @@
import 'dart:convert';
-import 'package:flutter/foundation.dart';
+import 'dart:io';
+import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:xml/xml.dart' as xml;
@@ -8,24 +9,49 @@ import '../models/nextcloud_item.dart';
extension XmlElementHelper on xml.XmlNode {
Iterable findLocalChildren(String localName) {
return children.whereType().where(
- (e) => e.name.local.toLowerCase() == localName.toLowerCase(),
- );
+ (e) => e.name.local.toLowerCase() == localName.toLowerCase(),
+ );
}
Iterable findLocalDescendants(String localName) {
if (this is xml.XmlDocument) {
- return (this as xml.XmlDocument).descendants.whereType().where(
- (e) => e.name.local.toLowerCase() == localName.toLowerCase(),
- );
+ return (this as xml.XmlDocument).descendants
+ .whereType()
+ .where((e) => e.name.local.toLowerCase() == localName.toLowerCase());
} else if (this is xml.XmlElement) {
- return (this as xml.XmlElement).descendants.whereType().where(
- (e) => e.name.local.toLowerCase() == localName.toLowerCase(),
- );
+ return (this as xml.XmlElement).descendants
+ .whereType()
+ .where((e) => e.name.local.toLowerCase() == localName.toLowerCase());
}
return [];
}
}
+/// WebDAV date props (`getlastmodified`, `creationdate`) come back as RFC 1123
+/// dates (e.g. "Mon, 01 Jan 2024 00:00:00 GMT"), which [DateTime.tryParse]
+/// can't read since it only understands ISO 8601. Fall back to [HttpDate].
+DateTime? _parseDavDate(String? raw) {
+ if (raw == null || raw.isEmpty) return null;
+ final iso = DateTime.tryParse(raw);
+ if (iso != null) return iso;
+ try {
+ return HttpDate.parse(raw);
+ } catch (_) {
+ return null;
+ }
+}
+
+/// Normalizes a WebDAV `href` (which servers may return as either a bare
+/// path or a full absolute URL) down to just its path, trailing slash
+/// stripped, so hrefs from either form can be compared directly.
+String _davPath(String value) {
+ var path = Uri.parse(value.trim()).path;
+ if (path.endsWith('/') && path.length > 1) {
+ path = path.substring(0, path.length - 1);
+ }
+ return path;
+}
+
class NextcloudService {
final String serverUrl;
final String username;
@@ -58,6 +84,44 @@ class NextcloudService {
};
}
+ /// Auth headers usable directly by widgets that fetch content themselves
+ /// (e.g. `Image.network(url, headers: service.authHeaders)`).
+ Map get authHeaders => _headers;
+
+ /// The direct WebDAV download URL for a file at [itemPath].
+ String fileUrl(String itemPath) {
+ var cleanPath = itemPath.trim();
+ if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
+ return '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath';
+ }
+
+ /// Downloads the file at [itemPath] to [savePath], reporting progress.
+ Future downloadToFile(
+ String itemPath,
+ String savePath, {
+ void Function(int received, int total)? onProgress,
+ }) async {
+ final dio = Dio();
+ await dio.download(
+ fileUrl(itemPath),
+ savePath,
+ options: Options(headers: _headers),
+ onReceiveProgress: onProgress,
+ );
+ }
+
+ /// Fetches the raw bytes of a file (used for in-app text/PDF previews).
+ Future> fetchBytes(String itemPath) async {
+ final response = await http.get(
+ Uri.parse(fileUrl(itemPath)),
+ headers: _headers,
+ );
+ if (response.statusCode != 200) {
+ throw Exception('Failed to download file. HTTP ${response.statusCode}');
+ }
+ return response.bodyBytes;
+ }
+
Future testConnection() async {
try {
final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/';
@@ -75,7 +139,9 @@ class NextcloudService {
''';
final streamed = await http.Client().send(request);
- debugPrint('[Nextcloud Test] Connection response HTTP status: ${streamed.statusCode}');
+ debugPrint(
+ '[Nextcloud Test] Connection response HTTP status: ${streamed.statusCode}',
+ );
if (streamed.statusCode == 207 || streamed.statusCode == 200) {
return true;
@@ -83,7 +149,9 @@ class NextcloudService {
// Try fallback endpoint /remote.php/webdav/
final legacyPath = '$_cleanServerUrl/remote.php/webdav/';
- debugPrint('[Nextcloud Test] Testing legacy WebDAV endpoint: $legacyPath');
+ debugPrint(
+ '[Nextcloud Test] Testing legacy WebDAV endpoint: $legacyPath',
+ );
final req2 = http.Request('PROPFIND', Uri.parse(legacyPath))
..headers.addAll({
..._headers,
@@ -91,7 +159,9 @@ class NextcloudService {
'Content-Type': 'application/xml',
});
final st2 = await http.Client().send(req2);
- debugPrint('[Nextcloud Test] Legacy WebDAV response HTTP status: ${st2.statusCode}');
+ debugPrint(
+ '[Nextcloud Test] Legacy WebDAV response HTTP status: ${st2.statusCode}',
+ );
if (st2.statusCode == 207 || st2.statusCode == 200) {
return true;
}
@@ -106,7 +176,9 @@ class NextcloudService {
} catch (e) {
debugPrint('[Nextcloud Test] Error: $e');
final str = e.toString();
- if (str.contains('XMLHttpRequest') || str.contains('ClientException') || str.contains('Failed to fetch')) {
+ if (str.contains('XMLHttpRequest') ||
+ str.contains('ClientException') ||
+ str.contains('Failed to fetch')) {
throw Exception(
'Browser CORS Policy Blocked: The web browser blocked the connection preflight request to $_cleanServerUrl. '
'To bypass browser CORS, run the application as a native Windows app (flutter run -d windows) '
@@ -134,15 +206,17 @@ class NextcloudService {
});
const body = '''
-
+
+
+
''';
@@ -151,11 +225,16 @@ class NextcloudService {
var streamedResponse = await http.Client().send(request);
var responseBody = await streamedResponse.stream.bytesToString();
- debugPrint('[Nextcloud DAV] PROPFIND primary URL status: ${streamedResponse.statusCode}, bytes: ${responseBody.length}');
+ debugPrint(
+ '[Nextcloud DAV] PROPFIND primary URL status: ${streamedResponse.statusCode}, bytes: ${responseBody.length}',
+ );
- if (streamedResponse.statusCode != 207 && streamedResponse.statusCode != 200) {
+ if (streamedResponse.statusCode != 207 &&
+ streamedResponse.statusCode != 200) {
final fallbackUrl = '$_cleanServerUrl/remote.php/webdav$cleanPath';
- debugPrint('[Nextcloud DAV] Primary URL failed (${streamedResponse.statusCode}). Trying fallback: $fallbackUrl');
+ debugPrint(
+ '[Nextcloud DAV] Primary URL failed (${streamedResponse.statusCode}). Trying fallback: $fallbackUrl',
+ );
final req2 = http.Request('PROPFIND', Uri.parse(fallbackUrl))
..headers.addAll({
..._headers,
@@ -165,51 +244,207 @@ class NextcloudService {
..body = body;
final res2 = await http.Client().send(req2);
final body2 = await res2.stream.bytesToString();
- debugPrint('[Nextcloud DAV] Fallback URL response status: ${res2.statusCode}, bytes: ${body2.length}');
+ debugPrint(
+ '[Nextcloud DAV] Fallback URL response status: ${res2.statusCode}, bytes: ${body2.length}',
+ );
if (res2.statusCode == 207 || res2.statusCode == 200) {
streamedResponse = res2;
responseBody = body2;
} else {
- throw Exception('Failed to load directory $cleanPath. HTTP ${streamedResponse.statusCode}');
+ throw Exception(
+ 'Failed to load directory $cleanPath. HTTP ${streamedResponse.statusCode}',
+ );
}
}
final document = xml.XmlDocument.parse(responseBody);
final responses = document.findLocalDescendants('response');
- debugPrint('[Nextcloud DAV] Found ${responses.length} response nodes in XML');
+ debugPrint(
+ '[Nextcloud DAV] Found ${responses.length} response nodes in XML',
+ );
List items = [];
for (var res in responses) {
- final hrefNode = res.findLocalChildren('href').firstOrNull ?? res.findLocalDescendants('href').firstOrNull;
+ final hrefNode =
+ res.findLocalChildren('href').firstOrNull ??
+ res.findLocalDescendants('href').firstOrNull;
final href = hrefNode?.innerText ?? '';
- var normalizedHref = Uri.decodeFull(href).trim();
- if (normalizedHref.endsWith('/') && normalizedHref.length > 1) {
- normalizedHref = normalizedHref.substring(0, normalizedHref.length - 1);
- }
-
- var targetDav = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath'.trim();
- if (targetDav.endsWith('/') && targetDav.length > 1) {
- targetDav = targetDav.substring(0, targetDav.length - 1);
- }
-
- var targetLegacy = '$_cleanServerUrl/remote.php/webdav$cleanPath'.trim();
- if (targetLegacy.endsWith('/') && targetLegacy.length > 1) {
- targetLegacy = targetLegacy.substring(0, targetLegacy.length - 1);
- }
+ // Servers may return `href` as either a server-relative path or a full
+ // absolute URL; compare on path only so both forms line up.
+ final hrefPath = _davPath(Uri.decodeFull(href));
+ final targetDavPath = _davPath(
+ '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath',
+ );
+ final targetLegacyPath = _davPath(
+ '$_cleanServerUrl/remote.php/webdav$cleanPath',
+ );
// Skip current root directory entry itself
- if (normalizedHref == targetDav ||
- normalizedHref == targetLegacy ||
- normalizedHref.endsWith('/remote.php/dav/files/$username') ||
- normalizedHref.endsWith('/remote.php/webdav')) {
+ if (hrefPath == targetDavPath ||
+ hrefPath == targetLegacyPath ||
+ hrefPath.endsWith('/remote.php/dav/files/$username') ||
+ hrefPath.endsWith('/remote.php/webdav')) {
continue;
}
final props = res.findLocalDescendants('prop');
if (props.isEmpty) continue;
+ bool isCollection = false;
+ String? sizeStr;
+ String? lastModStr;
+ String? createdStr;
+ String? mimeType;
+ bool isFav = false;
+ String? fileId;
+ String? mountType;
+
+ for (var prop in props) {
+ if (!isCollection) {
+ final resTypeNode = prop
+ .findLocalChildren('resourcetype')
+ .firstOrNull;
+ if (resTypeNode != null &&
+ resTypeNode.findLocalChildren('collection').isNotEmpty) {
+ isCollection = true;
+ }
+ }
+ sizeStr ??=
+ prop.findLocalChildren('size').firstOrNull?.innerText ??
+ prop.findLocalChildren('getcontentlength').firstOrNull?.innerText;
+ lastModStr ??= prop
+ .findLocalChildren('getlastmodified')
+ .firstOrNull
+ ?.innerText;
+ createdStr ??= prop
+ .findLocalChildren('creationdate')
+ .firstOrNull
+ ?.innerText;
+ mimeType ??= prop
+ .findLocalChildren('getcontenttype')
+ .firstOrNull
+ ?.innerText;
+ if (!isFav) {
+ isFav =
+ prop.findLocalChildren('favorite').firstOrNull?.innerText == '1';
+ }
+ fileId ??= prop.findLocalChildren('fileid').firstOrNull?.innerText;
+ mountType ??= prop
+ .findLocalChildren('mount-type')
+ .firstOrNull
+ ?.innerText;
+ }
+
+ final name = Uri.decodeFull(
+ href.split('/').where((s) => s.isNotEmpty).last,
+ );
+ if (name.isEmpty) continue;
+
+ final size = int.tryParse(sizeStr ?? '0') ?? 0;
+ final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
+ final created = _parseDavDate(createdStr);
+ final itemType = NextcloudItem.deduceType(name, isCollection, mimeType);
+ final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
+
+ items.add(
+ NextcloudItem(
+ id: validId,
+ name: name,
+ path: '$cleanPath$name',
+ type: itemType,
+ size: size,
+ lastModified: lastMod,
+ dateCreated: created,
+ isFavorite: isFav,
+ mimeType: mimeType,
+ previewUrl:
+ '$_cleanServerUrl/core/preview?fileId=$validId&x=500&y=500',
+ mountType: mountType,
+ ),
+ );
+ }
+
+ debugPrint(
+ '[Nextcloud DAV] Successfully parsed ${items.length} items from directory $cleanPath',
+ );
+ return items;
+ }
+
+ /// Searches the whole file tree by name using the WebDAV SEARCH-REPORT
+ /// extension (supported by Nextcloud/ownCloud), rather than just the
+ /// currently open folder.
+ Future> searchFiles(String query) async {
+ final term = query.trim();
+ if (term.isEmpty) return [];
+
+ final url = '$_cleanServerUrl/remote.php/dav/';
+ debugPrint('[Nextcloud DAV] SEARCH "$term" from $url');
+
+ final escaped = term
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>');
+ final body =
+ '''
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /files/$username
+ infinity
+
+
+
+
+
+ %$escaped%
+
+
+
+
+''';
+
+ final request = http.Request('SEARCH', Uri.parse(url))
+ ..headers.addAll({..._headers, 'Content-Type': 'text/xml'})
+ ..body = body;
+
+ final streamed = await http.Client().send(request);
+ final responseBody = await streamed.stream.bytesToString();
+ debugPrint(
+ '[Nextcloud DAV] SEARCH status: ${streamed.statusCode}, bytes: ${responseBody.length}',
+ );
+
+ if (streamed.statusCode != 207 && streamed.statusCode != 200) {
+ throw Exception('Search failed. HTTP ${streamed.statusCode}');
+ }
+
+ final document = xml.XmlDocument.parse(responseBody);
+ final responses = document.findLocalDescendants('response');
+
+ final results = [];
+ for (var res in responses) {
+ final hrefNode =
+ res.findLocalChildren('href').firstOrNull ??
+ res.findLocalDescendants('href').firstOrNull;
+ final href = hrefNode?.innerText ?? '';
+ if (href.isEmpty) continue;
+
+ final props = res.findLocalDescendants('prop');
+ if (props.isEmpty) continue;
+
bool isCollection = false;
String? sizeStr;
String? lastModStr;
@@ -219,44 +454,73 @@ class NextcloudService {
for (var prop in props) {
if (!isCollection) {
- final resTypeNode = prop.findLocalChildren('resourcetype').firstOrNull;
- if (resTypeNode != null && resTypeNode.findLocalChildren('collection').isNotEmpty) {
+ final resTypeNode = prop
+ .findLocalChildren('resourcetype')
+ .firstOrNull;
+ if (resTypeNode != null &&
+ resTypeNode.findLocalChildren('collection').isNotEmpty) {
isCollection = true;
}
}
- sizeStr ??= prop.findLocalChildren('size').firstOrNull?.innerText ??
- prop.findLocalChildren('getcontentlength').firstOrNull?.innerText;
- lastModStr ??= prop.findLocalChildren('getlastmodified').firstOrNull?.innerText;
- mimeType ??= prop.findLocalChildren('getcontenttype').firstOrNull?.innerText;
+ sizeStr ??= prop
+ .findLocalChildren('getcontentlength')
+ .firstOrNull
+ ?.innerText;
+ lastModStr ??= prop
+ .findLocalChildren('getlastmodified')
+ .firstOrNull
+ ?.innerText;
+ mimeType ??= prop
+ .findLocalChildren('getcontenttype')
+ .firstOrNull
+ ?.innerText;
if (!isFav) {
- isFav = prop.findLocalChildren('favorite').firstOrNull?.innerText == '1';
+ isFav =
+ prop.findLocalChildren('favorite').firstOrNull?.innerText == '1';
}
fileId ??= prop.findLocalChildren('fileid').firstOrNull?.innerText;
}
- final name = Uri.decodeFull(href.split('/').where((s) => s.isNotEmpty).last);
+ var decodedHref = Uri.decodeFull(href);
+ if (decodedHref.endsWith('/') && decodedHref.length > 1) {
+ decodedHref = decodedHref.substring(0, decodedHref.length - 1);
+ }
+ final name = decodedHref.split('/').where((s) => s.isNotEmpty).last;
if (name.isEmpty) continue;
+ // Strip the DAV root prefix so `path` matches what fetchDirectory produces.
+ final marker = '/files/$username';
+ final markerIndex = decodedHref.indexOf(marker);
+ final itemPath = markerIndex >= 0
+ ? decodedHref.substring(markerIndex + marker.length)
+ : decodedHref;
+ if (itemPath.isEmpty) continue;
+
final size = int.tryParse(sizeStr ?? '0') ?? 0;
- final lastMod = lastModStr != null ? DateTime.tryParse(lastModStr) ?? DateTime.now() : DateTime.now();
+ final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
final itemType = NextcloudItem.deduceType(name, isCollection, mimeType);
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
- items.add(NextcloudItem(
- id: validId,
- name: name,
- path: '$cleanPath$name',
- type: itemType,
- size: size,
- lastModified: lastMod,
- isFavorite: isFav,
- mimeType: mimeType,
- previewUrl: '$_cleanServerUrl/core/preview?fileId=$validId&x=500&y=500',
- ));
+ results.add(
+ NextcloudItem(
+ id: validId,
+ name: name,
+ path: itemPath,
+ type: itemType,
+ size: size,
+ lastModified: lastMod,
+ isFavorite: isFav,
+ mimeType: mimeType,
+ previewUrl:
+ '$_cleanServerUrl/core/preview?fileId=$validId&x=500&y=500',
+ ),
+ );
}
- debugPrint('[Nextcloud DAV] Successfully parsed ${items.length} items from directory $cleanPath');
- return items;
+ debugPrint(
+ '[Nextcloud DAV] Search returned ${results.length} results for "$term"',
+ );
+ return results;
}
Future fetchUserQuota() async {
@@ -264,7 +528,9 @@ class NextcloudService {
debugPrint('[Nextcloud OCS] Fetching user quota from $url');
final response = await http.get(Uri.parse(url), headers: _headers);
- debugPrint('[Nextcloud OCS] User quota response status: ${response.statusCode}');
+ debugPrint(
+ '[Nextcloud OCS] User quota response status: ${response.statusCode}',
+ );
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
@@ -287,17 +553,22 @@ class NextcloudService {
serverVersion: 'Nextcloud Server',
);
} else {
- throw Exception('Failed to fetch user quota. HTTP ${response.statusCode}');
+ throw Exception(
+ 'Failed to fetch user quota. HTTP ${response.statusCode}',
+ );
}
}
Future> fetchActivities() async {
try {
- final url = '$_cleanServerUrl/ocs/v2.php/apps/activity/api/v2/activity?format=json';
+ final url =
+ '$_cleanServerUrl/ocs/v2.php/apps/activity/api/v2/activity?format=json';
debugPrint('[Nextcloud OCS] Fetching activity feed from $url');
final response = await http.get(Uri.parse(url), headers: _headers);
- debugPrint('[Nextcloud OCS] Activity response status: ${response.statusCode}');
+ debugPrint(
+ '[Nextcloud OCS] Activity response status: ${response.statusCode}',
+ );
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
@@ -314,7 +585,9 @@ class NextcloudService {
id: (a['activity_id'] ?? '').toString(),
title: a['subject'] ?? 'Server Activity',
subject: a['message'] ?? (a['subject'] ?? ''),
- timestamp: DateTime.fromMillisecondsSinceEpoch((a['timestamp'] as int? ?? 0) * 1000),
+ timestamp: DateTime.fromMillisecondsSinceEpoch(
+ (a['timestamp'] as int? ?? 0) * 1000,
+ ),
icon: Icons.cloud_outlined,
author: a['user'] ?? username,
);
@@ -326,24 +599,42 @@ class NextcloudService {
return [];
}
- Future uploadFile(String folderPath, String fileName, Uint8List fileBytes) async {
+ /// Uploads a file from disk, streaming it so large files don't need to be
+ /// buffered fully in memory, with progress reporting.
+ Future uploadFileFromPath(
+ String folderPath,
+ String fileName,
+ String localFilePath, {
+ void Function(int sent, int total)? onProgress,
+ }) async {
var cleanPath = folderPath.trim();
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
if (!cleanPath.endsWith('/')) cleanPath = '$cleanPath/';
- final url = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath$fileName';
- debugPrint('[Nextcloud DAV] Uploading file to $url');
+ final url =
+ '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath$fileName';
+ debugPrint('[Nextcloud DAV] Streaming upload to $url');
- final response = await http.put(
- Uri.parse(url),
- headers: {
- ..._headers,
- 'Content-Type': 'application/octet-stream',
- },
- body: fileBytes,
+ final file = File(localFilePath);
+ final length = await file.length();
+ final dio = Dio();
+
+ final response = await dio.put(
+ url,
+ data: file.openRead(),
+ options: Options(
+ headers: {..._headers, Headers.contentLengthHeader: length},
+ contentType: 'application/octet-stream',
+ ),
+ onSendProgress: onProgress,
);
- debugPrint('[Nextcloud DAV] Upload status: ${response.statusCode}');
- return response.statusCode == 201 || response.statusCode == 204 || response.statusCode == 200;
+
+ debugPrint(
+ '[Nextcloud DAV] Streaming upload status: ${response.statusCode}',
+ );
+ return response.statusCode == 201 ||
+ response.statusCode == 204 ||
+ response.statusCode == 200;
}
Future deleteItem(String itemPath) async {
@@ -363,16 +654,21 @@ class NextcloudService {
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
if (!cleanPath.endsWith('/')) cleanPath = '$cleanPath/';
- final url = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath$folderName';
+ final url =
+ '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath$folderName';
debugPrint('[Nextcloud DAV] Creating folder at $url');
- final request = http.Request('MKCOL', Uri.parse(url))..headers.addAll(_headers);
+ final request = http.Request('MKCOL', Uri.parse(url))
+ ..headers.addAll(_headers);
final response = await http.Client().send(request);
debugPrint('[Nextcloud DAV] Create folder status: ${response.statusCode}');
return response.statusCode == 201;
}
- Future toggleFavorite(String itemPath, bool currentFavoriteState) async {
+ Future toggleFavorite(
+ String itemPath,
+ bool currentFavoriteState,
+ ) async {
var cleanPath = itemPath.trim();
if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath';
@@ -380,7 +676,8 @@ class NextcloudService {
final newFavVal = currentFavoriteState ? '0' : '1';
debugPrint('[Nextcloud DAV] Toggling favorite ($newFavVal) for $url');
- final body = '''
+ final body =
+ '''
@@ -390,14 +687,13 @@ class NextcloudService {
''';
final request = http.Request('PROPPATCH', Uri.parse(url))
- ..headers.addAll({
- ..._headers,
- 'Content-Type': 'application/xml',
- })
+ ..headers.addAll({..._headers, 'Content-Type': 'application/xml'})
..body = body;
final response = await http.Client().send(request);
- debugPrint('[Nextcloud DAV] Toggle favorite status: ${response.statusCode}');
+ debugPrint(
+ '[Nextcloud DAV] Toggle favorite status: ${response.statusCode}',
+ );
return response.statusCode == 207 || response.statusCode == 200;
}
}
diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart
index 282120b..31fb1bf 100644
--- a/lib/theme/app_theme.dart
+++ b/lib/theme/app_theme.dart
@@ -12,35 +12,38 @@ class AppTheme {
Color(0xFF2E7D32), // Emerald Green
];
- static ThemeData light(Color seedColor) {
- final colorScheme = ColorScheme.fromSeed(
- seedColor: seedColor,
- brightness: Brightness.light,
- );
+ static ThemeData light(
+ Color seedColor, {
+ ColorScheme? dynamicScheme,
+ bool useDynamicColor = true,
+ }) {
+ final colorScheme = (useDynamicColor && dynamicScheme != null)
+ ? dynamicScheme
+ : ColorScheme.fromSeed(
+ seedColor: seedColor,
+ brightness: Brightness.light,
+ );
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: colorScheme,
scaffoldBackgroundColor: colorScheme.surface,
- textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme).copyWith(
- headlineMedium: GoogleFonts.inter(
- fontWeight: FontWeight.w600,
- letterSpacing: -0.5,
- ),
- titleLarge: GoogleFonts.inter(
- fontWeight: FontWeight.w600,
- letterSpacing: -0.2,
- ),
- titleMedium: GoogleFonts.inter(
- fontWeight: FontWeight.w500,
- ),
- ),
+ textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme)
+ .copyWith(
+ headlineMedium: GoogleFonts.inter(
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.5,
+ ),
+ titleLarge: GoogleFonts.inter(
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.2,
+ ),
+ titleMedium: GoogleFonts.inter(fontWeight: FontWeight.w500),
+ ),
cardTheme: CardThemeData(
elevation: 0,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(20),
- ),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
color: colorScheme.surfaceContainerLow,
),
appBarTheme: AppBarTheme(
@@ -57,35 +60,38 @@ class AppTheme {
);
}
- static ThemeData dark(Color seedColor) {
- final colorScheme = ColorScheme.fromSeed(
- seedColor: seedColor,
- brightness: Brightness.dark,
- );
+ static ThemeData dark(
+ Color seedColor, {
+ ColorScheme? dynamicScheme,
+ bool useDynamicColor = true,
+ }) {
+ final colorScheme = (useDynamicColor && dynamicScheme != null)
+ ? dynamicScheme
+ : ColorScheme.fromSeed(
+ seedColor: seedColor,
+ brightness: Brightness.dark,
+ );
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: colorScheme,
scaffoldBackgroundColor: colorScheme.surface,
- textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme).copyWith(
- headlineMedium: GoogleFonts.inter(
- fontWeight: FontWeight.w600,
- letterSpacing: -0.5,
- ),
- titleLarge: GoogleFonts.inter(
- fontWeight: FontWeight.w600,
- letterSpacing: -0.2,
- ),
- titleMedium: GoogleFonts.inter(
- fontWeight: FontWeight.w500,
- ),
- ),
+ textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme)
+ .copyWith(
+ headlineMedium: GoogleFonts.inter(
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.5,
+ ),
+ titleLarge: GoogleFonts.inter(
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.2,
+ ),
+ titleMedium: GoogleFonts.inter(fontWeight: FontWeight.w500),
+ ),
cardTheme: CardThemeData(
elevation: 0,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(20),
- ),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
color: colorScheme.surfaceContainerLow,
),
appBarTheme: AppBarTheme(
diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart
index bb3c28d..bc93365 100644
--- a/lib/views/account_view.dart
+++ b/lib/views/account_view.dart
@@ -2,11 +2,10 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/server_provider.dart';
import '../theme/app_theme.dart';
+import '../widgets/frosted_glass_container.dart';
class AccountView extends StatelessWidget {
- final ScrollController scrollController;
-
- const AccountView({super.key, required this.scrollController});
+ const AccountView({super.key});
String _formatBytes(int bytes) {
if (bytes <= 0) return '0 GB';
@@ -21,298 +20,320 @@ class AccountView extends StatelessWidget {
final provider = context.watch();
final quota = provider.quota;
- return CustomScrollView(
- controller: scrollController,
- physics: const BouncingScrollPhysics(),
- slivers: [
- SliverToBoxAdapter(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- 'Account & Storage',
- style: theme.textTheme.headlineMedium?.copyWith(
- fontWeight: FontWeight.w700,
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Account & Storage'),
+ actions: [
+ IconButton(
+ onPressed: () => _showLogoutConfirmation(context, provider),
+ icon: const Icon(Icons.logout_rounded),
+ tooltip: 'Log Out',
+ color: colorScheme.error,
+ ),
+ ],
+ ),
+ body: SafeArea(
+ child: ListView(
+ padding: const EdgeInsets.fromLTRB(20, 8, 20, 32),
+ physics: const BouncingScrollPhysics(),
+ children: [
+ Text(
+ 'Connected to ${provider.serverUrl}',
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 20),
+
+ // User Profile & Storage Card
+ Container(
+ padding: const EdgeInsets.all(20),
+ decoration: BoxDecoration(
+ color: colorScheme.primaryContainer.withValues(alpha: 0.4),
+ borderRadius: BorderRadius.circular(28),
+ border: Border.all(
+ color: colorScheme.primary.withValues(alpha: 0.2),
+ ),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ CircleAvatar(
+ radius: 28,
+ backgroundColor: colorScheme.primary,
+ child: Text(
+ (quota?.userName ?? provider.username)
+ .substring(0, 1)
+ .toUpperCase(),
+ style: theme.textTheme.headlineSmall?.copyWith(
+ color: colorScheme.onPrimary,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ quota?.userName ?? provider.username,
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ quota?.email ?? provider.username,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10,
+ vertical: 4,
+ ),
+ decoration: BoxDecoration(
+ color: provider.isLoggedIn
+ ? Colors.green.withValues(alpha: 0.2)
+ : Colors.red.withValues(alpha: 0.2),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(
+ provider.isLoggedIn ? 'ONLINE' : 'OFFLINE',
+ style: TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.bold,
+ color: provider.isLoggedIn
+ ? Colors.green.shade800
+ : Colors.red.shade800,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 20),
+ const Divider(height: 1),
+ const SizedBox(height: 16),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ 'Storage Quota',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ Text(
+ quota != null
+ ? (quota.totalBytes > 0
+ ? '${_formatBytes(quota.usedBytes)} of ${_formatBytes(quota.totalBytes)}'
+ : '${_formatBytes(quota.usedBytes)} (Unlimited)')
+ : 'Live Server Storage',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(6),
+ child: LinearProgressIndicator(
+ value: quota != null && quota.totalBytes > 0
+ ? quota.usagePercentage
+ : 0.1,
+ minHeight: 10,
+ backgroundColor: colorScheme.surfaceContainerHighest,
+ valueColor: AlwaysStoppedAnimation(
+ colorScheme.primary,
),
),
- IconButton.filledTonal(
- onPressed: () {
- _showLogoutConfirmation(context, provider);
- },
- icon: const Icon(Icons.logout_rounded),
- tooltip: 'Log Out',
- style: IconButton.styleFrom(
- foregroundColor: colorScheme.error,
- backgroundColor: colorScheme.errorContainer.withValues(alpha: 0.5),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ quota != null
+ ? (quota.totalBytes > 0
+ ? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used'
+ : 'Unlimited Storage Plan')
+ : 'WebDAV connection active',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ fontSize: 11,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 24),
+
+ // Server Credentials Section
+ Text(
+ 'Server Connection Info',
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 10),
+ Card(
+ child: Column(
+ children: [
+ ListTile(
+ leading: const Icon(Icons.dns_rounded),
+ title: const Text('Nextcloud Host'),
+ subtitle: Text(provider.serverUrl),
+ ),
+ const Divider(height: 1, indent: 16, endIndent: 16),
+ ListTile(
+ leading: const Icon(Icons.person_rounded),
+ title: const Text('Logged In User'),
+ subtitle: Text(provider.username),
+ ),
+ const Divider(height: 1, indent: 16, endIndent: 16),
+ ListTile(
+ leading: const Icon(Icons.refresh_rounded),
+ title: const Text('Refresh WebDAV Cache'),
+ onTap: () async {
+ await provider.refreshData();
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Refreshed WebDAV directory data'),
+ behavior: SnackBarBehavior.floating,
+ ),
+ );
+ }
+ },
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 24),
+
+ // Material You Design Settings
+ Text(
+ 'Material You Aesthetics',
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 10),
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Accent Color',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ provider.useDynamicColor
+ ? 'Matching your wallpaper'
+ : 'Custom color',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 12),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
+ children: [
+ _AccentSwatch(
+ isSelected: provider.useDynamicColor,
+ onTap: () => provider.setUseDynamicColor(true),
+ borderColor: colorScheme.onSurface,
+ background: colorScheme.surfaceContainerHighest,
+ child: Icon(
+ Icons.wallpaper_rounded,
+ color: colorScheme.onSurfaceVariant,
+ size: 20,
+ ),
+ ),
+ ...AppTheme.seedColors.map((color) {
+ final isSelected =
+ !provider.useDynamicColor &&
+ provider.seedColor == color;
+ return _AccentSwatch(
+ isSelected: isSelected,
+ onTap: () => provider.setSeedColor(color),
+ borderColor: colorScheme.onSurface,
+ background: color,
+ child: isSelected
+ ? const Icon(
+ Icons.check_rounded,
+ color: Colors.white,
+ size: 20,
+ )
+ : null,
+ );
+ }),
+ ],
+ ),
+ const SizedBox(height: 20),
+ const Divider(height: 1),
+ const SizedBox(height: 16),
+ Text(
+ 'Theme Mode',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 12),
+ SizedBox(
+ width: double.infinity,
+ child: SegmentedButton(
+ segments: const [
+ ButtonSegment(
+ value: ThemeMode.system,
+ icon: Icon(Icons.brightness_auto_rounded),
+ label: Text('System'),
+ ),
+ ButtonSegment(
+ value: ThemeMode.light,
+ icon: Icon(Icons.light_mode_rounded),
+ label: Text('Light'),
+ ),
+ ButtonSegment(
+ value: ThemeMode.dark,
+ icon: Icon(Icons.dark_mode_rounded),
+ label: Text('Dark'),
+ ),
+ ],
+ selected: {provider.themeMode},
+ onSelectionChanged: (set) =>
+ provider.setThemeMode(set.first),
),
),
],
),
- const SizedBox(height: 4),
- Text(
- 'Connected to ${provider.serverUrl}',
- style: theme.textTheme.bodyMedium?.copyWith(
- color: colorScheme.onSurfaceVariant,
- ),
- ),
- const SizedBox(height: 20),
-
- // User Profile & Storage Card
- Container(
- padding: const EdgeInsets.all(20),
- decoration: BoxDecoration(
- color: colorScheme.primaryContainer.withValues(alpha: 0.4),
- borderRadius: BorderRadius.circular(28),
- border: Border.all(
- color: colorScheme.primary.withValues(alpha: 0.2),
- ),
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
- CircleAvatar(
- radius: 28,
- backgroundColor: colorScheme.primary,
- child: Text(
- (quota?.userName ?? provider.username).substring(0, 1).toUpperCase(),
- style: theme.textTheme.headlineSmall?.copyWith(
- color: colorScheme.onPrimary,
- fontWeight: FontWeight.bold,
- ),
- ),
- ),
- const SizedBox(width: 16),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- quota?.userName ?? provider.username,
- style: theme.textTheme.titleMedium?.copyWith(
- fontWeight: FontWeight.w700,
- ),
- ),
- const SizedBox(height: 2),
- Text(
- quota?.email ?? provider.username,
- style: theme.textTheme.bodySmall?.copyWith(
- color: colorScheme.onSurfaceVariant,
- ),
- ),
- ],
- ),
- ),
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
- decoration: BoxDecoration(
- color: provider.isLoggedIn ? Colors.green.withValues(alpha: 0.2) : Colors.red.withValues(alpha: 0.2),
- borderRadius: BorderRadius.circular(12),
- ),
- child: Text(
- provider.isLoggedIn ? 'ONLINE' : 'OFFLINE',
- style: TextStyle(
- fontSize: 11,
- fontWeight: FontWeight.bold,
- color: provider.isLoggedIn ? Colors.green.shade800 : Colors.red.shade800,
- ),
- ),
- ),
- ],
- ),
- const SizedBox(height: 20),
- const Divider(height: 1),
- const SizedBox(height: 16),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- 'Storage Quota',
- style: theme.textTheme.titleSmall?.copyWith(
- fontWeight: FontWeight.w600,
- ),
- ),
- Text(
- quota != null
- ? (quota.totalBytes > 0
- ? '${_formatBytes(quota.usedBytes)} of ${_formatBytes(quota.totalBytes)}'
- : '${_formatBytes(quota.usedBytes)} (Unlimited)')
- : 'Live Server Storage',
- style: theme.textTheme.bodySmall?.copyWith(
- color: colorScheme.onSurfaceVariant,
- fontWeight: FontWeight.w500,
- ),
- ),
- ],
- ),
- const SizedBox(height: 10),
- ClipRRect(
- borderRadius: BorderRadius.circular(6),
- child: LinearProgressIndicator(
- value: quota != null && quota.totalBytes > 0 ? quota.usagePercentage : 0.1,
- minHeight: 10,
- backgroundColor: colorScheme.surfaceContainerHighest,
- valueColor: AlwaysStoppedAnimation(colorScheme.primary),
- ),
- ),
- const SizedBox(height: 8),
- Text(
- quota != null
- ? (quota.totalBytes > 0
- ? '${(quota.usagePercentage * 100).toStringAsFixed(1)}% used'
- : 'Unlimited Storage Plan')
- : 'WebDAV connection active',
- style: theme.textTheme.bodySmall?.copyWith(
- color: colorScheme.onSurfaceVariant,
- fontSize: 11,
- ),
- ),
- ],
- ),
- ),
- const SizedBox(height: 24),
-
- // Server Credentials Section
- Text(
- 'Server Connection Info',
- style: theme.textTheme.titleMedium?.copyWith(
- fontWeight: FontWeight.w700,
- ),
- ),
- const SizedBox(height: 10),
- Card(
- child: Column(
- children: [
- ListTile(
- leading: const Icon(Icons.dns_rounded),
- title: const Text('Nextcloud Host'),
- subtitle: Text(provider.serverUrl),
- ),
- const Divider(height: 1, indent: 16, endIndent: 16),
- ListTile(
- leading: const Icon(Icons.person_rounded),
- title: const Text('Logged In User'),
- subtitle: Text(provider.username),
- ),
- const Divider(height: 1, indent: 16, endIndent: 16),
- ListTile(
- leading: const Icon(Icons.refresh_rounded),
- title: const Text('Refresh WebDAV Cache'),
- onTap: () async {
- await provider.refreshData();
- if (context.mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(
- content: Text('Refreshed WebDAV directory data'),
- behavior: SnackBarBehavior.floating,
- ),
- );
- }
- },
- ),
- ],
- ),
- ),
- const SizedBox(height: 24),
-
- // Material You Design Settings
- Text(
- 'Material You Aesthetics',
- style: theme.textTheme.titleMedium?.copyWith(
- fontWeight: FontWeight.w700,
- ),
- ),
- const SizedBox(height: 10),
- Card(
- child: Padding(
- padding: const EdgeInsets.all(16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- 'Dynamic Accent Color Seed',
- style: theme.textTheme.titleSmall?.copyWith(
- fontWeight: FontWeight.w600,
- ),
- ),
- const SizedBox(height: 12),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceAround,
- children: AppTheme.seedColors.map((color) {
- final isSelected = provider.seedColor == color;
- return GestureDetector(
- onTap: () => provider.setSeedColor(color),
- child: Container(
- width: 44,
- height: 44,
- decoration: BoxDecoration(
- color: color,
- shape: BoxShape.circle,
- border: isSelected
- ? Border.all(color: colorScheme.onSurface, width: 3)
- : null,
- ),
- child: isSelected
- ? const Icon(Icons.check_rounded, color: Colors.white, size: 20)
- : null,
- ),
- );
- }).toList(),
- ),
- const SizedBox(height: 20),
- const Divider(height: 1),
- const SizedBox(height: 16),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- 'Theme Mode',
- style: theme.textTheme.titleSmall?.copyWith(
- fontWeight: FontWeight.w600,
- ),
- ),
- SegmentedButton(
- segments: const [
- ButtonSegment(
- value: ThemeMode.system,
- icon: Icon(Icons.brightness_auto_rounded),
- label: Text('System'),
- ),
- ButtonSegment(
- value: ThemeMode.light,
- icon: Icon(Icons.light_mode_rounded),
- label: Text('Light'),
- ),
- ButtonSegment(
- value: ThemeMode.dark,
- icon: Icon(Icons.dark_mode_rounded),
- label: Text('Dark'),
- ),
- ],
- selected: {provider.themeMode},
- onSelectionChanged: (set) => provider.setThemeMode(set.first),
- ),
- ],
- ),
- ],
- ),
- ),
- ),
- ],
+ ),
),
- ),
- ),
+ const SizedBox(height: 24),
- const SliverToBoxAdapter(
- child: SizedBox(height: 100),
+ // UI Settings
+ Text(
+ 'UI',
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 10),
+ const _BottomBarAppearanceCard(),
+ ],
),
- ],
+ ),
);
}
@@ -322,7 +343,9 @@ class AccountView extends StatelessWidget {
builder: (context) {
return AlertDialog(
title: const Text('Log Out'),
- content: Text('Are you sure you want to disconnect from ${provider.serverUrl}?'),
+ content: Text(
+ 'Are you sure you want to disconnect from ${provider.serverUrl}?',
+ ),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
@@ -333,7 +356,7 @@ class AccountView extends StatelessWidget {
backgroundColor: Theme.of(context).colorScheme.error,
),
onPressed: () {
- Navigator.pop(context);
+ Navigator.of(context).popUntil((route) => route.isFirst);
provider.logout();
},
child: const Text('Log Out'),
@@ -344,3 +367,211 @@ class AccountView extends StatelessWidget {
);
}
}
+
+class _BottomBarAppearanceCard extends StatefulWidget {
+ const _BottomBarAppearanceCard();
+
+ @override
+ State<_BottomBarAppearanceCard> createState() =>
+ _BottomBarAppearanceCardState();
+}
+
+class _BottomBarAppearanceCardState extends State<_BottomBarAppearanceCard> {
+ // Mirrors the provider values locally so the slider thumb and the live
+ // preview track the drag gesture on every frame. Driving them straight off
+ // `provider.bottomBar*` instead would tie the slider's own responsiveness
+ // to a full Provider-wide rebuild — including the expensive BackdropFilter
+ // blur in the preview — which can't keep up with fast drag ticks and makes
+ // the thumb appear stuck until the next unrelated rebuild.
+ double? _opacity;
+ double? _blur;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+ final provider = context.watch();
+ final opacity = _opacity ?? provider.bottomBarOpacity;
+ final blur = _blur ?? provider.bottomBarBlur;
+
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Bottom Bar Appearance',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ 'Adjust the glass effect of the floating bottom bar',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 16),
+
+ // Live preview
+ Container(
+ height: 140,
+ width: double.infinity,
+ clipBehavior: Clip.antiAlias,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(20),
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [
+ colorScheme.primary,
+ colorScheme.tertiary,
+ colorScheme.secondary,
+ ],
+ ),
+ ),
+ child: Stack(
+ children: [
+ Positioned.fill(
+ child: CustomPaint(painter: _PreviewPatternPainter()),
+ ),
+ Align(
+ alignment: Alignment.bottomCenter,
+ child: Padding(
+ padding: const EdgeInsets.only(bottom: 16),
+ child: FrostedGlassContainer(
+ opacity: opacity,
+ blurSigma: blur,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 18,
+ vertical: 12,
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ Icons.folder_rounded,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(width: 16),
+ Icon(
+ Icons.photo_library_rounded,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(width: 16),
+ Icon(
+ Icons.history_rounded,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: 20),
+
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text('Opacity', style: theme.textTheme.bodyMedium),
+ Text(
+ '${(opacity * 100).round()}%',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ Slider(
+ value: opacity,
+ min: 0.1,
+ max: 1.0,
+ onChanged: (value) => setState(() => _opacity = value),
+ onChangeEnd: provider.setBottomBarOpacity,
+ ),
+
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text('Blur', style: theme.textTheme.bodyMedium),
+ Text(
+ blur.round().toString(),
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ Slider(
+ value: blur,
+ min: 0,
+ max: 40,
+ onChanged: (value) => setState(() => _blur = value),
+ onChangeEnd: provider.setBottomBarBlur,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _PreviewPatternPainter extends CustomPainter {
+ @override
+ void paint(Canvas canvas, Size size) {
+ final paint = Paint()
+ ..color = Colors.white.withValues(alpha: 0.25)
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 10;
+ for (var x = -size.height; x < size.width; x += 24) {
+ canvas.drawLine(
+ Offset(x, size.height),
+ Offset(x + size.height, 0),
+ paint,
+ );
+ }
+ }
+
+ @override
+ bool shouldRepaint(covariant _PreviewPatternPainter oldDelegate) => false;
+}
+
+class _AccentSwatch extends StatelessWidget {
+ final bool isSelected;
+ final VoidCallback onTap;
+ final Color borderColor;
+ final Color background;
+ final Widget? child;
+
+ const _AccentSwatch({
+ required this.isSelected,
+ required this.onTap,
+ required this.borderColor,
+ required this.background,
+ this.child,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ width: 44,
+ height: 44,
+ decoration: BoxDecoration(
+ color: background,
+ shape: BoxShape.circle,
+ border: isSelected ? Border.all(color: borderColor, width: 3) : null,
+ ),
+ child: child,
+ ),
+ );
+ }
+}
diff --git a/lib/views/activity_view.dart b/lib/views/activity_view.dart
index 1482f55..7100e90 100644
--- a/lib/views/activity_view.dart
+++ b/lib/views/activity_view.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../providers/server_provider.dart';
+import '../widgets/profile_avatar_button.dart';
class ActivityView extends StatelessWidget {
final ScrollController scrollController;
@@ -19,29 +20,16 @@ class ActivityView extends StatelessWidget {
controller: scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
- SliverToBoxAdapter(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- 'Activity Log',
- style: theme.textTheme.headlineMedium?.copyWith(
- fontWeight: FontWeight.w700,
- ),
- ),
- const SizedBox(height: 4),
- Text(
- 'Real-time server sync & file events',
- style: theme.textTheme.bodyMedium?.copyWith(
- color: colorScheme.onSurfaceVariant,
- ),
- ),
- ],
- ),
- ),
+ SliverAppBar(
+ pinned: true,
+ expandedHeight: kToolbarHeight,
+ collapsedHeight: kToolbarHeight,
+ backgroundColor: colorScheme.surface,
+ surfaceTintColor: colorScheme.surface,
+ scrolledUnderElevation: 0,
+ actions: const [ProfileAvatarButton()],
),
+ const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (activities.isEmpty)
SliverFillRemaining(
@@ -58,93 +46,92 @@ class ActivityView extends StatelessWidget {
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverList(
- delegate: SliverChildBuilderDelegate(
- (context, index) {
- final act = activities[index];
+ delegate: SliverChildBuilderDelegate((context, index) {
+ final act = activities[index];
- return Padding(
- padding: const EdgeInsets.only(bottom: 12),
- child: Container(
- padding: const EdgeInsets.all(16),
- decoration: BoxDecoration(
- color: colorScheme.surfaceContainerLow,
- borderRadius: BorderRadius.circular(20),
- border: Border.all(
- color: colorScheme.outlineVariant.withValues(alpha: 0.2),
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 12),
+ child: Container(
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: colorScheme.surfaceContainerLow,
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(
+ color: colorScheme.outlineVariant.withValues(
+ alpha: 0.2,
),
),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Container(
- padding: const EdgeInsets.all(10),
- decoration: BoxDecoration(
- color: colorScheme.primaryContainer,
- shape: BoxShape.circle,
- ),
- child: Icon(
- act.icon,
- color: colorScheme.onPrimaryContainer,
- size: 20,
- ),
- ),
- const SizedBox(width: 14),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- act.title,
- style: theme.textTheme.titleMedium?.copyWith(
- fontWeight: FontWeight.w600,
- fontSize: 14,
- ),
- ),
- Text(
- DateFormat.jm().format(act.timestamp),
- style: theme.textTheme.bodySmall?.copyWith(
- color: colorScheme.onSurfaceVariant,
- fontSize: 11,
- ),
- ),
- ],
- ),
- const SizedBox(height: 4),
- Text(
- act.subject,
- style: theme.textTheme.bodyMedium?.copyWith(
- color: colorScheme.onSurfaceVariant,
- fontSize: 13,
- ),
- ),
- const SizedBox(height: 6),
- Text(
- 'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}',
- style: theme.textTheme.bodySmall?.copyWith(
- color: colorScheme.primary,
- fontWeight: FontWeight.w500,
- fontSize: 11,
- ),
- ),
- ],
- ),
- ),
- ],
- ),
),
- );
- },
- childCount: activities.length,
- ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Container(
+ padding: const EdgeInsets.all(10),
+ decoration: BoxDecoration(
+ color: colorScheme.primaryContainer,
+ shape: BoxShape.circle,
+ ),
+ child: Icon(
+ act.icon,
+ color: colorScheme.onPrimaryContainer,
+ size: 20,
+ ),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment:
+ MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ act.title,
+ style: theme.textTheme.titleMedium
+ ?.copyWith(
+ fontWeight: FontWeight.w600,
+ fontSize: 14,
+ ),
+ ),
+ Text(
+ DateFormat.jm().format(act.timestamp),
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ fontSize: 11,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 4),
+ Text(
+ act.subject,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ fontSize: 13,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ 'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: colorScheme.primary,
+ fontWeight: FontWeight.w500,
+ fontSize: 11,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }, childCount: activities.length),
),
),
- const SliverToBoxAdapter(
- child: SizedBox(height: 100),
- ),
+ const SliverToBoxAdapter(child: SizedBox(height: 100)),
],
);
}
diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart
new file mode 100644
index 0000000..062635b
--- /dev/null
+++ b/lib/views/file_viewer_screen.dart
@@ -0,0 +1,1102 @@
+import 'dart:convert';
+import 'dart:math' as math;
+import 'dart:typed_data';
+import 'package:file_saver/file_saver.dart';
+import 'package:flutter/material.dart';
+import 'package:open_file/open_file.dart';
+import 'package:path/path.dart' as p;
+import 'package:path_provider/path_provider.dart';
+import 'package:pdfx/pdfx.dart';
+import 'package:provider/provider.dart';
+import 'package:share_plus/share_plus.dart';
+import 'package:video_player/video_player.dart';
+import '../models/nextcloud_item.dart';
+import '../providers/server_provider.dart';
+import '../widgets/frosted_glass_container.dart';
+
+const _textPreviewExtensions = {
+ '.txt',
+ '.md',
+ '.json',
+ '.yaml',
+ '.yml',
+ '.xml',
+ '.log',
+ '.csv',
+ '.dart',
+ '.js',
+ '.ts',
+ '.py',
+ '.java',
+ '.kt',
+ '.swift',
+ '.c',
+ '.cpp',
+ '.h',
+ '.sh',
+ '.bat',
+ '.ini',
+ '.conf',
+ '.gradle',
+ '.html',
+ '.css',
+};
+
+class FileViewerScreen extends StatefulWidget {
+ final NextcloudItem item;
+
+ /// The other items in the same folder/album, so images and videos can be
+ /// swiped through without leaving the viewer. Non-media items in this list
+ /// are ignored. If omitted (or the item isn't an image/video), the viewer
+ /// just shows [item] on its own.
+ final List? siblings;
+
+ const FileViewerScreen({super.key, required this.item, this.siblings});
+
+ /// Opens the viewer with no page transition — media should appear
+ /// instantly, not fade/zoom in the way MaterialPageRoute normally would.
+ static Route route({
+ required NextcloudItem item,
+ List? siblings,
+ }) {
+ return PageRouteBuilder(
+ pageBuilder: (context, animation, secondaryAnimation) =>
+ FileViewerScreen(item: item, siblings: siblings),
+ transitionDuration: Duration.zero,
+ reverseTransitionDuration: Duration.zero,
+ );
+ }
+
+ @override
+ State createState() => _FileViewerScreenState();
+}
+
+class _FileViewerScreenState extends State {
+ double? _downloadProgress;
+ bool _isBusy = false;
+ bool _controlsVisible = true;
+
+ late final bool _isSwipeable;
+ late final List _mediaItems;
+ late int _currentIndex;
+ late final PageController _pageController;
+
+ NextcloudItem get _currentItem => _mediaItems[_currentIndex];
+
+ bool get _isPdf => _currentItem.name.toLowerCase().endsWith('.pdf');
+ bool get _isText => _textPreviewExtensions.contains(
+ p.extension(_currentItem.name).toLowerCase(),
+ );
+
+ @override
+ void initState() {
+ super.initState();
+ _isSwipeable =
+ widget.item.type == NextcloudItemType.image ||
+ widget.item.type == NextcloudItemType.video;
+
+ if (_isSwipeable) {
+ final source = widget.siblings ?? [widget.item];
+ _mediaItems = source
+ .where(
+ (i) =>
+ i.type == NextcloudItemType.image ||
+ i.type == NextcloudItemType.video,
+ )
+ .toList();
+ if (_mediaItems.isEmpty) _mediaItems = [widget.item];
+ final idx = _mediaItems.indexWhere((i) => i.path == widget.item.path);
+ _currentIndex = idx >= 0 ? idx : 0;
+ } else {
+ _mediaItems = [widget.item];
+ _currentIndex = 0;
+ }
+ _pageController = PageController(initialPage: _currentIndex);
+ }
+
+ @override
+ void dispose() {
+ _pageController.dispose();
+ super.dispose();
+ }
+
+ Future _downloadToTemp(
+ ServerProvider provider,
+ NextcloudItem item,
+ ) async {
+ final tempDir = await getTemporaryDirectory();
+ final savePath = p.join(tempDir.path, item.name);
+ setState(() => _downloadProgress = 0);
+ await provider.service!.downloadToFile(
+ item.path,
+ savePath,
+ onProgress: (received, total) {
+ if (total > 0 && mounted) {
+ setState(() => _downloadProgress = received / total);
+ }
+ },
+ );
+ if (mounted) setState(() => _downloadProgress = null);
+ return savePath;
+ }
+
+ Future _openExternally(ServerProvider provider) async {
+ final item = _currentItem;
+ setState(() => _isBusy = true);
+ try {
+ final path = await _downloadToTemp(provider, item);
+ final result = await OpenFile.open(path);
+ if (mounted && result.type != ResultType.done) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Could not open file: ${result.message}')),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Failed to open ${item.name}: $e')),
+ );
+ }
+ } finally {
+ if (mounted) setState(() => _isBusy = false);
+ }
+ }
+
+ Future _downloadToDevice(ServerProvider provider) async {
+ final item = _currentItem;
+ setState(() => _isBusy = true);
+ try {
+ final tempPath = await _downloadToTemp(provider, item);
+ final ext = p.extension(item.name).replaceFirst('.', '');
+ final baseName = p.basenameWithoutExtension(item.name);
+ await FileSaver.instance.saveFile(
+ name: baseName,
+ filePath: tempPath,
+ ext: ext,
+ );
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Saved ${item.name} to Downloads')),
+ );
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('Download failed: $e')));
+ }
+ } finally {
+ if (mounted) setState(() => _isBusy = false);
+ }
+ }
+
+ Future _shareCurrentItem(ServerProvider provider) async {
+ final item = _currentItem;
+ setState(() => _isBusy = true);
+ try {
+ final path = await _downloadToTemp(provider, item);
+ await SharePlus.instance.share(ShareParams(files: [XFile(path)]));
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Could not share ${item.name}: $e')),
+ );
+ }
+ } finally {
+ if (mounted) setState(() => _isBusy = false);
+ }
+ }
+
+ Future _toggleFavorite(ServerProvider provider) async {
+ final item = _currentItem;
+ await provider.toggleItemFavorite(item);
+ if (!mounted) return;
+ setState(() {
+ _mediaItems[_currentIndex] = item.copyWith(isFavorite: !item.isFavorite);
+ });
+ }
+
+ Future _deleteCurrentItem(ServerProvider provider) async {
+ final item = _currentItem;
+ final confirmed = await showDialog(
+ context: context,
+ builder: (dialogContext) {
+ return AlertDialog(
+ title: const Text('Delete file'),
+ content: Text(
+ 'Delete "${item.name}" from the server? This cannot be undone.',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(dialogContext, false),
+ child: const Text('Cancel'),
+ ),
+ FilledButton(
+ style: FilledButton.styleFrom(
+ backgroundColor: Theme.of(dialogContext).colorScheme.error,
+ ),
+ onPressed: () => Navigator.pop(dialogContext, true),
+ child: const Text('Delete'),
+ ),
+ ],
+ );
+ },
+ );
+ if (confirmed != true || !mounted) return;
+
+ setState(() => _isBusy = true);
+ final success = await provider.deleteItem(item.path);
+ if (!mounted) return;
+ setState(() => _isBusy = false);
+
+ if (!success) {
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('Failed to delete ${item.name}')));
+ return;
+ }
+
+ setState(() {
+ _mediaItems.removeAt(_currentIndex);
+ if (_mediaItems.isNotEmpty && _currentIndex >= _mediaItems.length) {
+ _currentIndex = _mediaItems.length - 1;
+ }
+ });
+
+ if (_mediaItems.isEmpty) {
+ if (mounted) Navigator.pop(context);
+ return;
+ }
+ if (_isSwipeable && _pageController.hasClients) {
+ _pageController.jumpToPage(_currentIndex);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final provider = context.read();
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Scaffold(
+ backgroundColor: colorScheme.surface,
+ body: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: () => setState(() => _controlsVisible = !_controlsVisible),
+ child: Stack(
+ children: [
+ Positioned.fill(
+ child: Container(
+ color: colorScheme.surface,
+ child: _buildBody(context, provider),
+ ),
+ ),
+ if (_downloadProgress != null)
+ Positioned(
+ left: 0,
+ right: 0,
+ top: 0,
+ child: LinearProgressIndicator(
+ value: _downloadProgress,
+ color: colorScheme.primary,
+ backgroundColor: colorScheme.onSurface.withValues(
+ alpha: 0.16,
+ ),
+ ),
+ ),
+ IgnorePointer(
+ ignoring: !_controlsVisible,
+ child: AnimatedOpacity(
+ opacity: _controlsVisible ? 1 : 0,
+ duration: const Duration(milliseconds: 200),
+ child: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.only(left: 20, top: 12),
+ child: FrostedGlassContainer(
+ opacity: provider.bottomBarOpacity,
+ blurSigma: provider.bottomBarBlur,
+ child: SizedBox(
+ width: 48,
+ height: 48,
+ child: IconButton(
+ icon: const Icon(Icons.arrow_back_rounded),
+ color: colorScheme.onSurfaceVariant,
+ onPressed: () => Navigator.pop(context),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ Align(
+ alignment: Alignment.bottomCenter,
+ child: AnimatedSlide(
+ duration: const Duration(milliseconds: 220),
+ curve: Curves.easeInOutCubic,
+ offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4),
+ child: IgnorePointer(
+ ignoring: !_controlsVisible,
+ child: _MediaActionBar(
+ isFavorite: _currentItem.isFavorite,
+ isBusy: _isBusy,
+ opacity: provider.bottomBarOpacity,
+ blurSigma: provider.bottomBarBlur,
+ onShare: () => _shareCurrentItem(provider),
+ onFavorite: () => _toggleFavorite(provider),
+ onDelete: () => _deleteCurrentItem(provider),
+ onOpenExternally: () => _openExternally(provider),
+ onDownload: () => _downloadToDevice(provider),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildBody(BuildContext context, ServerProvider provider) {
+ if (!_isSwipeable) {
+ return _buildStaticPreview(context, provider);
+ }
+
+ return PageView.builder(
+ controller: _pageController,
+ itemCount: _mediaItems.length,
+ onPageChanged: (index) => setState(() => _currentIndex = index),
+ itemBuilder: (context, index) {
+ final mediaItem = _mediaItems[index];
+ if (mediaItem.type == NextcloudItemType.image) {
+ return _ImagePreview(
+ key: ValueKey(mediaItem.id),
+ url: provider.service!.fileUrl(mediaItem.path),
+ headers: provider.service!.authHeaders,
+ );
+ }
+ return _VideoPreview(
+ key: ValueKey(mediaItem.id),
+ url: provider.service!.fileUrl(mediaItem.path),
+ headers: provider.service!.authHeaders,
+ isActive: index == _currentIndex,
+ controlsBottomOffset: _controlsVisible ? 108 : 24,
+ opacity: provider.bottomBarOpacity,
+ blurSigma: provider.bottomBarBlur,
+ );
+ },
+ );
+ }
+
+ Widget _buildStaticPreview(BuildContext context, ServerProvider provider) {
+ switch (widget.item.type) {
+ case NextcloudItemType.image:
+ case NextcloudItemType.video:
+ // Unreachable: these types are always handled by the swipeable path.
+ return const SizedBox.shrink();
+ default:
+ if (_isPdf) {
+ return _PdfPreview(item: widget.item, provider: provider);
+ }
+ if (_isText) {
+ return _TextPreview(item: widget.item, provider: provider);
+ }
+ return _UnsupportedPreview(
+ item: widget.item,
+ isBusy: _isBusy,
+ onOpenExternally: () => _openExternally(provider),
+ );
+ }
+ }
+}
+
+/// The floating action bar (share/favorite/open/download/delete), sized and
+/// positioned to match the app-wide frosted-glass bottom navigation bar.
+class _MediaActionBar extends StatelessWidget {
+ final bool isFavorite;
+ final bool isBusy;
+ final double opacity;
+ final double blurSigma;
+ final VoidCallback onShare;
+ final VoidCallback onFavorite;
+ final VoidCallback onDelete;
+ final VoidCallback onOpenExternally;
+ final VoidCallback onDownload;
+
+ const _MediaActionBar({
+ required this.isFavorite,
+ required this.isBusy,
+ required this.opacity,
+ required this.blurSigma,
+ required this.onShare,
+ required this.onFavorite,
+ required this.onDelete,
+ required this.onOpenExternally,
+ required this.onDownload,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.only(bottom: 20, left: 20, right: 20),
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 480),
+ child: FrostedGlassContainer(
+ opacity: opacity,
+ blurSigma: blurSigma,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: [
+ _ActionIconButton(
+ icon: Icons.share_rounded,
+ tooltip: 'Share',
+ onTap: isBusy ? null : onShare,
+ ),
+ _ActionIconButton(
+ icon: isFavorite
+ ? Icons.star_rounded
+ : Icons.star_outline_rounded,
+ tooltip: 'Favorite',
+ color: isFavorite ? Colors.amber : null,
+ onTap: onFavorite,
+ ),
+ _ActionIconButton(
+ icon: Icons.open_in_new_rounded,
+ tooltip: 'Open externally',
+ onTap: isBusy ? null : onOpenExternally,
+ ),
+ _ActionIconButton(
+ icon: Icons.download_rounded,
+ tooltip: 'Download',
+ onTap: isBusy ? null : onDownload,
+ ),
+ _ActionIconButton(
+ icon: Icons.delete_outline_rounded,
+ tooltip: 'Delete',
+ color: colorScheme.error,
+ onTap: isBusy ? null : onDelete,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _ActionIconButton extends StatelessWidget {
+ final IconData icon;
+ final String tooltip;
+ final VoidCallback? onTap;
+ final Color? color;
+
+ const _ActionIconButton({
+ required this.icon,
+ required this.tooltip,
+ required this.onTap,
+ this.color,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final fg = onTap == null
+ ? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
+ : (color ?? colorScheme.onSurfaceVariant);
+
+ return Tooltip(
+ message: tooltip,
+ child: InkWell(
+ onTap: onTap,
+ customBorder: const CircleBorder(),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
+ child: Icon(icon, color: fg, size: 22),
+ ),
+ ),
+ );
+ }
+}
+
+class _ImagePreview extends StatelessWidget {
+ final String url;
+ final Map headers;
+
+ const _ImagePreview({super.key, required this.url, required this.headers});
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return SizedBox.expand(
+ child: InteractiveViewer(
+ minScale: 0.8,
+ maxScale: 5.0,
+ child: Center(
+ child: Image.network(
+ url,
+ headers: headers,
+ fit: BoxFit.contain,
+ loadingBuilder: (context, child, progress) {
+ if (progress == null) return child;
+ return Center(
+ child: CircularProgressIndicator(color: colorScheme.primary),
+ );
+ },
+ errorBuilder: (context, error, stack) => Center(
+ child: Icon(
+ Icons.broken_image_outlined,
+ color: colorScheme.onSurfaceVariant,
+ size: 64,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _VideoPreview extends StatefulWidget {
+ final String url;
+ final Map headers;
+ final bool isActive;
+
+ /// Distance from the bottom of the screen the transport controls should
+ /// sit at. Passed in by the parent so the controls can track the floating
+ /// action bar: resting just above it when visible, sliding down to hug
+ /// the screen edge when the bar (and rest of the chrome) is hidden.
+ final double controlsBottomOffset;
+
+ /// Opacity/blur for the transport controls' frosted-glass background,
+ /// matching the app-wide bottom bar and media action bar styling.
+ final double opacity;
+ final double blurSigma;
+
+ const _VideoPreview({
+ super.key,
+ required this.url,
+ required this.headers,
+ this.isActive = true,
+ this.controlsBottomOffset = 24,
+ this.opacity = 0.55,
+ this.blurSigma = 28,
+ });
+
+ @override
+ State<_VideoPreview> createState() => _VideoPreviewState();
+}
+
+class _VideoPreviewState extends State<_VideoPreview> {
+ VideoPlayerController? _controller;
+ String? _error;
+
+ @override
+ void initState() {
+ super.initState();
+ _init();
+ }
+
+ Future _init() async {
+ try {
+ final controller = VideoPlayerController.networkUrl(
+ Uri.parse(widget.url),
+ httpHeaders: widget.headers,
+ );
+ await controller.initialize();
+ if (!mounted) {
+ controller.dispose();
+ return;
+ }
+ setState(() => _controller = controller);
+ if (widget.isActive) controller.play();
+ } catch (e) {
+ if (mounted) setState(() => _error = e.toString());
+ }
+ }
+
+ @override
+ void didUpdateWidget(covariant _VideoPreview oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.isActive != widget.isActive) {
+ if (widget.isActive) {
+ _controller?.play();
+ } else {
+ _controller?.pause();
+ }
+ }
+ }
+
+ @override
+ void dispose() {
+ _controller?.dispose();
+ super.dispose();
+ }
+
+ String _formatDuration(Duration d) {
+ final minutes = d.inMinutes.remainder(60);
+ final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
+ return '$minutes:$seconds';
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ if (_error != null) {
+ return Center(
+ child: Text(
+ 'Could not play video: $_error',
+ style: TextStyle(color: colorScheme.onSurfaceVariant),
+ ),
+ );
+ }
+ final controller = _controller;
+ if (controller == null || !controller.value.isInitialized) {
+ return Center(
+ child: CircularProgressIndicator(color: colorScheme.primary),
+ );
+ }
+
+ return SizedBox.expand(
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ Center(
+ child: AspectRatio(
+ aspectRatio: controller.value.aspectRatio,
+ child: VideoPlayer(controller),
+ ),
+ ),
+ AnimatedPositioned(
+ duration: const Duration(milliseconds: 220),
+ curve: Curves.easeInOutCubic,
+ left: 16,
+ right: 16,
+ bottom: widget.controlsBottomOffset,
+ child: AnimatedBuilder(
+ animation: controller,
+ builder: (context, _) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final fg = colorScheme.onSurfaceVariant;
+ // Same frosted-glass treatment (and user opacity/blur
+ // settings) as the back button and media action bar, so the
+ // transport controls match the rest of the app's chrome.
+ return FrostedGlassContainer(
+ borderRadius: 24,
+ opacity: widget.opacity,
+ blurSigma: widget.blurSigma,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 4,
+ vertical: 4,
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(
+ children: [
+ IconButton(
+ icon: Icon(
+ controller.value.isPlaying
+ ? Icons.pause_rounded
+ : Icons.play_arrow_rounded,
+ color: fg,
+ ),
+ onPressed: () => controller.value.isPlaying
+ ? controller.pause()
+ : controller.play(),
+ ),
+ Expanded(
+ child: Text(
+ '${_formatDuration(controller.value.position)} / ${_formatDuration(controller.value.duration)}',
+ textAlign: TextAlign.center,
+ style: TextStyle(color: fg, fontSize: 13),
+ ),
+ ),
+ IconButton(
+ icon: Icon(
+ controller.value.volume == 0
+ ? Icons.volume_off_rounded
+ : Icons.volume_up_rounded,
+ color: fg,
+ ),
+ onPressed: () => controller.setVolume(
+ controller.value.volume == 0 ? 1 : 0,
+ ),
+ ),
+ ],
+ ),
+ Padding(
+ padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
+ child: _WavySeekBar(
+ controller: controller,
+ playedColor: fg,
+ trackColor: fg.withValues(alpha: 0.3),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// A Material You "expressive" wavy seek bar: the played portion of the
+/// track animates as a travelling sine wave while playing and settles flat
+/// when paused, matching the Android 16+ media player style. Drag or tap
+/// anywhere on it to seek.
+class _WavySeekBar extends StatefulWidget {
+ final VideoPlayerController controller;
+ final Color playedColor;
+ final Color trackColor;
+
+ const _WavySeekBar({
+ required this.controller,
+ required this.playedColor,
+ required this.trackColor,
+ });
+
+ @override
+ State<_WavySeekBar> createState() => _WavySeekBarState();
+}
+
+class _WavySeekBarState extends State<_WavySeekBar>
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _waveController = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 900),
+ );
+ double? _dragRatio;
+
+ @override
+ void dispose() {
+ _waveController.dispose();
+ super.dispose();
+ }
+
+ void _seekToRatio(double ratio) {
+ final duration = widget.controller.value.duration;
+ widget.controller.seekTo(duration * ratio.clamp(0.0, 1.0));
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final value = widget.controller.value;
+ final isPlaying = value.isPlaying && _dragRatio == null;
+ if (isPlaying && !_waveController.isAnimating) {
+ _waveController.repeat();
+ } else if (!isPlaying && _waveController.isAnimating) {
+ _waveController.stop();
+ }
+
+ final durationMs = value.duration.inMilliseconds;
+ final baseRatio = durationMs > 0
+ ? value.position.inMilliseconds / durationMs
+ : 0.0;
+ final ratio = (_dragRatio ?? baseRatio).clamp(0.0, 1.0);
+
+ return SizedBox(
+ height: 28,
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ void updateFromDx(double dx) {
+ setState(
+ () => _dragRatio = (dx / constraints.maxWidth).clamp(0.0, 1.0),
+ );
+ }
+
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onHorizontalDragStart: (details) =>
+ updateFromDx(details.localPosition.dx),
+ onHorizontalDragUpdate: (details) =>
+ updateFromDx(details.localPosition.dx),
+ onHorizontalDragEnd: (_) {
+ if (_dragRatio != null) _seekToRatio(_dragRatio!);
+ setState(() => _dragRatio = null);
+ },
+ onTapUp: (details) =>
+ _seekToRatio(details.localPosition.dx / constraints.maxWidth),
+ child: AnimatedBuilder(
+ animation: _waveController,
+ builder: (context, _) {
+ return CustomPaint(
+ size: Size(constraints.maxWidth, 28),
+ painter: _WavySeekPainter(
+ progress: ratio,
+ phase: _waveController.value * 2 * math.pi,
+ animate: isPlaying,
+ playedColor: widget.playedColor,
+ trackColor: widget.trackColor,
+ ),
+ );
+ },
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
+class _WavySeekPainter extends CustomPainter {
+ final double progress;
+ final double phase;
+ final bool animate;
+ final Color playedColor;
+ final Color trackColor;
+
+ static const _waveLength = 16.0;
+ static const _amplitude = 3.5;
+
+ _WavySeekPainter({
+ required this.progress,
+ required this.phase,
+ required this.animate,
+ required this.playedColor,
+ required this.trackColor,
+ });
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final midY = size.height / 2;
+ final playedX = size.width * progress;
+
+ final trackPaint = Paint()
+ ..color = trackColor
+ ..strokeWidth = 3
+ ..strokeCap = StrokeCap.round;
+ if (playedX < size.width) {
+ canvas.drawLine(
+ Offset(playedX, midY),
+ Offset(size.width, midY),
+ trackPaint,
+ );
+ }
+
+ final playedPaint = Paint()
+ ..color = playedColor
+ ..strokeWidth = 3
+ ..strokeCap = StrokeCap.round
+ ..style = PaintingStyle.stroke;
+
+ final path = Path();
+ var started = false;
+ for (var x = 0.0; x <= playedX; x += 2) {
+ final y = animate
+ ? midY +
+ _amplitude * math.sin((x / _waveLength) * 2 * math.pi + phase)
+ : midY;
+ if (!started) {
+ path.moveTo(x, y);
+ started = true;
+ } else {
+ path.lineTo(x, y);
+ }
+ }
+ canvas.drawPath(path, playedPaint);
+
+ canvas.drawCircle(Offset(playedX, midY), 6, Paint()..color = playedColor);
+ }
+
+ @override
+ bool shouldRepaint(covariant _WavySeekPainter oldDelegate) {
+ return oldDelegate.progress != progress ||
+ oldDelegate.phase != phase ||
+ oldDelegate.animate != animate ||
+ oldDelegate.playedColor != playedColor ||
+ oldDelegate.trackColor != trackColor;
+ }
+}
+
+class _PdfPreview extends StatefulWidget {
+ final NextcloudItem item;
+ final ServerProvider provider;
+
+ const _PdfPreview({required this.item, required this.provider});
+
+ @override
+ State<_PdfPreview> createState() => _PdfPreviewState();
+}
+
+class _PdfPreviewState extends State<_PdfPreview> {
+ PdfControllerPinch? _controller;
+ String? _error;
+
+ @override
+ void initState() {
+ super.initState();
+ _load();
+ }
+
+ Future _load() async {
+ try {
+ final bytes = await widget.provider.service!.fetchBytes(widget.item.path);
+ if (!mounted) return;
+ setState(() {
+ _controller = PdfControllerPinch(
+ document: PdfDocument.openData(Uint8List.fromList(bytes)),
+ );
+ });
+ } catch (e) {
+ if (mounted) setState(() => _error = e.toString());
+ }
+ }
+
+ @override
+ void dispose() {
+ _controller?.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ if (_error != null) {
+ return Center(
+ child: Text(
+ 'Could not load PDF: $_error',
+ style: TextStyle(color: colorScheme.onSurfaceVariant),
+ ),
+ );
+ }
+ if (_controller == null) {
+ return Center(
+ child: CircularProgressIndicator(color: colorScheme.primary),
+ );
+ }
+ return PdfViewPinch(controller: _controller!);
+ }
+}
+
+class _TextPreview extends StatefulWidget {
+ final NextcloudItem item;
+ final ServerProvider provider;
+
+ const _TextPreview({required this.item, required this.provider});
+
+ @override
+ State<_TextPreview> createState() => _TextPreviewState();
+}
+
+class _TextPreviewState extends State<_TextPreview> {
+ String? _content;
+ String? _error;
+
+ @override
+ void initState() {
+ super.initState();
+ _load();
+ }
+
+ Future _load() async {
+ try {
+ final bytes = await widget.provider.service!.fetchBytes(widget.item.path);
+ if (mounted) {
+ setState(() => _content = utf8.decode(bytes, allowMalformed: true));
+ }
+ } catch (e) {
+ if (mounted) setState(() => _error = e.toString());
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ if (_error != null) {
+ return Center(
+ child: Text(
+ 'Could not load file: $_error',
+ style: TextStyle(color: colorScheme.onSurfaceVariant),
+ ),
+ );
+ }
+ if (_content == null) {
+ return Center(
+ child: CircularProgressIndicator(color: colorScheme.primary),
+ );
+ }
+ return Container(
+ color: colorScheme.surface,
+ padding: const EdgeInsets.all(20),
+ child: SingleChildScrollView(
+ child: SelectableText(
+ _content!,
+ style: TextStyle(
+ color: colorScheme.onSurface,
+ fontFamily: 'monospace',
+ fontSize: 13,
+ height: 1.5,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _UnsupportedPreview extends StatelessWidget {
+ final NextcloudItem item;
+ final bool isBusy;
+ final VoidCallback onOpenExternally;
+
+ const _UnsupportedPreview({
+ required this.item,
+ required this.isBusy,
+ required this.onOpenExternally,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(32),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ Icons.insert_drive_file_rounded,
+ color: colorScheme.onSurfaceVariant,
+ size: 72,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ item.name,
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ color: colorScheme.onSurface,
+ fontWeight: FontWeight.w600,
+ fontSize: 16,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'No inline preview for this file type.',
+ style: TextStyle(
+ color: colorScheme.onSurfaceVariant,
+ fontSize: 13,
+ ),
+ ),
+ const SizedBox(height: 28),
+ FilledButton.icon(
+ onPressed: isBusy ? null : onOpenExternally,
+ icon: const Icon(Icons.open_in_new_rounded),
+ label: const Text('Open with...'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart
index 1afe278..6141ba3 100644
--- a/lib/views/files_view.dart
+++ b/lib/views/files_view.dart
@@ -1,60 +1,362 @@
-import 'dart:typed_data';
+import 'package:file_picker/file_picker.dart';
+import 'package:file_saver/file_saver.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
+import 'package:material_symbols_icons/symbols.dart';
+import 'package:path/path.dart' as p;
+import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
+import '../widgets/profile_avatar_button.dart';
+import '../widgets/segmented_icon_toggle.dart';
+import 'file_viewer_screen.dart';
-class FilesView extends StatelessWidget {
+String _formatBytes(int bytes) {
+ if (bytes <= 0) return '0 B';
+ if (bytes < 1024) return '$bytes B';
+ if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
+ if (bytes < 1024 * 1024 * 1024) {
+ return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
+ }
+ return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
+}
+
+class FilesView extends StatefulWidget {
final ScrollController scrollController;
const FilesView({super.key, required this.scrollController});
- String _formatBytes(int bytes) {
- if (bytes <= 0) return '0 B';
- if (bytes < 1024) return '$bytes B';
- if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
- if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
- return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
+ @override
+ State createState() => _FilesViewState();
+}
+
+IconData _getItemIcon(NextcloudItemType type) {
+ switch (type) {
+ case NextcloudItemType.folder:
+ return Icons.folder_rounded;
+ case NextcloudItemType.image:
+ return Icons.image_rounded;
+ case NextcloudItemType.video:
+ return Icons.movie_rounded;
+ case NextcloudItemType.audio:
+ return Icons.audiotrack_rounded;
+ case NextcloudItemType.document:
+ return Icons.description_rounded;
+ case NextcloudItemType.archive:
+ return Icons.folder_zip_rounded;
+ case NextcloudItemType.file:
+ return Icons.insert_drive_file_rounded;
+ }
+}
+
+Color _getIconColor(BuildContext context, NextcloudItemType type) {
+ final colorScheme = Theme.of(context).colorScheme;
+ switch (type) {
+ case NextcloudItemType.folder:
+ return colorScheme.primary;
+ case NextcloudItemType.image:
+ return Colors.amber.shade700;
+ case NextcloudItemType.video:
+ return Colors.deepOrange.shade600;
+ case NextcloudItemType.audio:
+ return Colors.purple.shade600;
+ case NextcloudItemType.document:
+ return Colors.blue.shade700;
+ case NextcloudItemType.archive:
+ return Colors.teal.shade700;
+ case NextcloudItemType.file:
+ return colorScheme.outline;
+ }
+}
+
+/// A file/folder's icon badge — for images and videos this shows an actual
+/// thumbnail (falling back to the plain icon on error or if there's no
+/// preview URL yet), matching the real-content treatment already used in
+/// the Photos tab.
+class _ItemThumbnail extends StatelessWidget {
+ final NextcloudItem item;
+ final ServerProvider provider;
+ final double size;
+ final double borderRadius;
+ final double iconSize;
+
+ const _ItemThumbnail({
+ required this.item,
+ required this.provider,
+ required this.size,
+ required this.borderRadius,
+ required this.iconSize,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final iconColor = _getIconColor(context, item.type);
+ final isMedia =
+ item.type == NextcloudItemType.image ||
+ item.type == NextcloudItemType.video;
+ // Decode straight to the size this thumbnail is actually painted at —
+ // the server hands back a 500x500 preview regardless, and decoding that
+ // in full for a ~40dp tile (times however many are on screen while
+ // scrolling) is a real source of jank.
+ final cachePixels = (size * MediaQuery.of(context).devicePixelRatio)
+ .round();
+
+ return Container(
+ width: size,
+ height: size,
+ clipBehavior: Clip.antiAlias,
+ decoration: BoxDecoration(
+ color: iconColor.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(borderRadius),
+ ),
+ child: isMedia && item.previewUrl != null
+ ? Stack(
+ fit: StackFit.expand,
+ children: [
+ Image.network(
+ item.previewUrl!,
+ headers: provider.service?.authHeaders,
+ fit: BoxFit.cover,
+ cacheWidth: cachePixels,
+ cacheHeight: cachePixels,
+ filterQuality: FilterQuality.low,
+ gaplessPlayback: true,
+ loadingBuilder: (context, child, progress) {
+ if (progress == null) return child;
+ return Center(
+ child: Icon(
+ _getItemIcon(item.type),
+ color: iconColor,
+ size: iconSize,
+ ),
+ );
+ },
+ errorBuilder: (context, error, stack) => Center(
+ child: Icon(
+ _getItemIcon(item.type),
+ color: iconColor,
+ size: iconSize,
+ ),
+ ),
+ ),
+ if (item.type == NextcloudItemType.video)
+ const Center(
+ child: Icon(
+ Icons.play_circle_fill_rounded,
+ color: Colors.white,
+ size: 18,
+ ),
+ ),
+ ],
+ )
+ : Center(
+ child: Icon(
+ _getItemIcon(item.type),
+ color: iconColor,
+ size: iconSize,
+ ),
+ ),
+ );
+ }
+}
+
+String _sortFieldLabel(FileSortField field) {
+ switch (field) {
+ case FileSortField.name:
+ return 'Name';
+ case FileSortField.dateCreated:
+ return 'Date created';
+ case FileSortField.dateModified:
+ return 'Date modified';
+ case FileSortField.size:
+ return 'Size';
+ }
+}
+
+/// Dropdown trigger (replacing the old "My files" label) for choosing which
+/// field the file list is sorted by.
+class _SortMenuButton extends StatelessWidget {
+ final FileSortField field;
+ final ValueChanged onChanged;
+
+ const _SortMenuButton({required this.field, required this.onChanged});
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+
+ return PopupMenuButton(
+ initialValue: field,
+ onSelected: onChanged,
+ offset: const Offset(0, 36),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ itemBuilder: (context) => FileSortField.values.map((f) {
+ return PopupMenuItem(
+ value: f,
+ child: Row(
+ children: [
+ SizedBox(
+ width: 20,
+ child: f == field
+ ? Icon(
+ Icons.check_rounded,
+ size: 18,
+ color: colorScheme.primary,
+ )
+ : null,
+ ),
+ const SizedBox(width: 8),
+ Text(_sortFieldLabel(f)),
+ ],
+ ),
+ );
+ }).toList(),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ _sortFieldLabel(field),
+ style: theme.textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ Icon(
+ Icons.arrow_drop_down_rounded,
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// Tappable "Home / Folder / Sub-folder" trail shown under the sort/filter
+/// row whenever the user has navigated below the root.
+class _Breadcrumbs extends StatelessWidget {
+ final List pathStack;
+ final ValueChanged onTap;
+
+ const _Breadcrumbs({required this.pathStack, required this.onTap});
+
+ String _labelFor(int index) {
+ if (index == 0) return 'Home';
+ final segments = pathStack[index]
+ .split('/')
+ .where((s) => s.isNotEmpty)
+ .toList();
+ return segments.isEmpty ? 'Home' : segments.last;
}
- IconData _getItemIcon(NextcloudItemType type) {
- switch (type) {
- case NextcloudItemType.folder:
- return Icons.folder_rounded;
- case NextcloudItemType.image:
- return Icons.image_rounded;
- case NextcloudItemType.video:
- return Icons.movie_rounded;
- case NextcloudItemType.audio:
- return Icons.audiotrack_rounded;
- case NextcloudItemType.document:
- return Icons.description_rounded;
- case NextcloudItemType.archive:
- return Icons.folder_zip_rounded;
- case NextcloudItemType.file:
- return Icons.insert_drive_file_rounded;
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+ final lastIndex = pathStack.length - 1;
+
+ return SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: [
+ for (var i = 0; i < pathStack.length; i++) ...[
+ if (i > 0)
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 2),
+ child: Icon(
+ Icons.chevron_right_rounded,
+ size: 16,
+ color: colorScheme.outlineVariant,
+ ),
+ ),
+ if (i == lastIndex)
+ Text(
+ _labelFor(i),
+ style: theme.textTheme.bodySmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ color: colorScheme.onSurface,
+ ),
+ )
+ else
+ InkWell(
+ borderRadius: BorderRadius.circular(8),
+ onTap: () => onTap(i),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 2,
+ vertical: 2,
+ ),
+ child: Text(
+ _labelFor(i),
+ style: theme.textTheme.bodySmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ color: colorScheme.primary,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
+
+class _FilesViewState extends State {
+ static const double _lockThreshold = 100;
+ static const double _syncThreshold = 14;
+ static const double _pullingEpsilon = 8;
+
+ bool _headerLocked = false;
+ bool _isPulling = false;
+ double _pullDistance = 0;
+
+ void _revealPanel() {
+ if (widget.scrollController.hasClients) {
+ widget.scrollController.animateTo(
+ 0,
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeOutCubic,
+ );
}
}
- Color _getIconColor(BuildContext context, NextcloudItemType type) {
- final colorScheme = Theme.of(context).colorScheme;
- switch (type) {
- case NextcloudItemType.folder:
- return colorScheme.primary;
- case NextcloudItemType.image:
- return Colors.amber.shade700;
- case NextcloudItemType.video:
- return Colors.deepOrange.shade600;
- case NextcloudItemType.audio:
- return Colors.purple.shade600;
- case NextcloudItemType.document:
- return Colors.blue.shade700;
- case NextcloudItemType.archive:
- return Colors.teal.shade700;
- case NextcloudItemType.file:
- return colorScheme.outline;
+ void _lockOpen() {
+ if (!_headerLocked) setState(() => _headerLocked = true);
+ _revealPanel();
+ }
+
+ bool _handleScrollNotification(ScrollNotification notification) {
+ final metrics = notification.metrics;
+ if (metrics.pixels < metrics.minScrollExtent) {
+ _pullDistance = metrics.minScrollExtent - metrics.pixels;
+ final pulling = _pullDistance > _pullingEpsilon;
+ if (pulling != _isPulling && !_headerLocked) {
+ setState(() => _isPulling = pulling);
+ }
+ } else if (_isPulling) {
+ setState(() => _isPulling = false);
}
+
+ if (notification is ScrollEndNotification) {
+ final pulled = _pullDistance;
+ _pullDistance = 0;
+ if (_isPulling) setState(() => _isPulling = false);
+
+ if (pulled >= _lockThreshold) {
+ _lockOpen();
+ context.read().refreshData();
+ } else if (pulled >= _syncThreshold) {
+ context.read().refreshData();
+ }
+ }
+ return false;
}
@override
@@ -63,247 +365,267 @@ class FilesView extends StatelessWidget {
final colorScheme = theme.colorScheme;
final provider = context.watch();
- return CustomScrollView(
- controller: scrollController,
- physics: const BouncingScrollPhysics(),
- slivers: [
- // App Bar / Header
- SliverToBoxAdapter(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- 'Nextcloud Files',
- style: theme.textTheme.headlineMedium?.copyWith(
- color: colorScheme.onSurface,
- fontWeight: FontWeight.w700,
- ),
- ),
- const SizedBox(height: 2),
- Text(
- '${provider.username} @ ${provider.serverUrl}',
- style: theme.textTheme.bodyMedium?.copyWith(
- color: colorScheme.onSurfaceVariant,
- ),
- ),
- ],
+ final Widget leadingWidget;
+ if (_headerLocked) {
+ leadingWidget = IconButton(
+ icon: const Icon(Icons.keyboard_arrow_up_rounded),
+ tooltip: 'Collapse',
+ onPressed: () => setState(() => _headerLocked = false),
+ );
+ } else if (_isPulling) {
+ leadingWidget = const SizedBox.shrink();
+ } else {
+ leadingWidget = _SyncStatusChip(provider: provider, onTap: _lockOpen);
+ }
+
+ final List contentSlivers = [
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ IconButton(
+ icon: Icon(
+ provider.sortAscending
+ ? Icons.arrow_upward_rounded
+ : Icons.arrow_downward_rounded,
+ size: 20,
),
- Row(
- children: [
- IconButton.filledTonal(
- onPressed: () => _showCreateFolderDialog(context, provider),
- icon: const Icon(Icons.create_new_folder_outlined),
- tooltip: 'New Folder',
- ),
- const SizedBox(width: 8),
- IconButton.filled(
- onPressed: () => _showUploadDialog(context, provider),
- icon: const Icon(Icons.upload_file_rounded),
- tooltip: 'Upload File',
- ),
- ],
+ visualDensity: VisualDensity.compact,
+ tooltip: provider.sortAscending
+ ? 'Ascending'
+ : 'Descending',
+ onPressed: provider.toggleSortOrder,
+ ),
+ Expanded(
+ child: _SortMenuButton(
+ field: provider.sortField,
+ onChanged: provider.setSortField,
),
- ],
- ),
- const SizedBox(height: 16),
- // Search & Filter Bar
- Row(
- children: [
- Expanded(
- child: Container(
- height: 48,
- decoration: BoxDecoration(
- color: colorScheme.surfaceContainerHigh,
- borderRadius: BorderRadius.circular(24),
- ),
- child: TextField(
- onChanged: provider.setSearchQuery,
- decoration: InputDecoration(
- hintText: 'Search files & folders...',
- hintStyle: TextStyle(
- color: colorScheme.onSurfaceVariant,
- fontSize: 14,
- ),
- prefixIcon: Icon(
- Icons.search_rounded,
- color: colorScheme.onSurfaceVariant,
- ),
- border: InputBorder.none,
- contentPadding: const EdgeInsets.symmetric(vertical: 12),
- ),
- ),
- ),
- ),
- const SizedBox(width: 8),
- FilterChip(
- selected: provider.showFavoritesOnly,
- label: const Icon(Icons.star_rounded, size: 18),
- onSelected: (_) => provider.toggleFavoritesFilter(),
- shape: const CircleBorder(),
- padding: const EdgeInsets.all(8),
- selectedColor: colorScheme.primaryContainer,
- ),
- const SizedBox(width: 4),
- IconButton(
- onPressed: provider.toggleViewMode,
- icon: Icon(
- provider.isGridView
- ? Icons.view_list_rounded
- : Icons.grid_view_rounded,
- color: colorScheme.onSurfaceVariant,
- ),
- ),
- ],
- ),
- const SizedBox(height: 12),
- // Breadcrumbs & Path Navigator
- if (provider.pathStack.length > 1)
- Row(
+ ),
+ ToggleIconButton(
+ icon: Icons.star_rounded,
+ isSelected: provider.showFavoritesOnly,
+ onTap: provider.toggleFavoritesFilter,
+ tooltip: 'Favorites only',
+ ),
+ const SizedBox(width: 4),
+ ToggleIconButton(
+ icon: Symbols.hard_drive_rounded,
+ isSelected: provider.showExternalOnly,
+ onTap: provider.toggleExternalStorageFilter,
+ tooltip: 'External storage only',
+ ),
+ const SizedBox(width: 4),
+ ToggleIconButton(
+ icon: Icons.visibility_rounded,
+ isSelected: provider.showHidden,
+ onTap: provider.toggleShowHidden,
+ tooltip: 'Show hidden files',
+ ),
+ const SizedBox(width: 8),
+ SegmentedIconGroup(
children: [
- ActionChip(
- avatar: const Icon(Icons.arrow_back_rounded, size: 16),
- label: const Text('Back'),
- onPressed: provider.navigateUp,
- backgroundColor: colorScheme.surfaceContainerLow,
+ ToggleIconButton(
+ icon: Icons.view_list_rounded,
+ isSelected: !provider.isGridView,
+ onTap: () => provider.setGridView(false),
+ tooltip: 'List view',
),
- const SizedBox(width: 8),
- Expanded(
- child: SingleChildScrollView(
- scrollDirection: Axis.horizontal,
- child: Text(
- provider.currentFolderPath,
- style: theme.textTheme.bodyMedium?.copyWith(
- fontWeight: FontWeight.w600,
- color: colorScheme.primary,
- ),
- ),
- ),
+ ToggleIconButton(
+ icon: Icons.grid_view_rounded,
+ isSelected: provider.isGridView,
+ onTap: () => provider.setGridView(true),
+ tooltip: 'Grid view',
),
],
),
+ ],
+ ),
+ if (provider.pathStack.length > 1) ...[
+ const SizedBox(height: 10),
+ _Breadcrumbs(
+ pathStack: provider.pathStack,
+ onTap: (index) => provider.navigateToPathIndex(index),
+ ),
],
- ),
+ ],
),
),
+ ),
- // Files List / Grid
- if (provider.isLoading)
- const SliverFillRemaining(
- child: Center(
- child: CircularProgressIndicator(),
- ),
- )
- else if (provider.errorMessage != null)
- SliverFillRemaining(
- child: Center(
- child: Padding(
- padding: const EdgeInsets.all(24),
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Icon(
- Icons.error_outline_rounded,
- size: 64,
- color: colorScheme.error,
- ),
- const SizedBox(height: 16),
- Text(
- 'WebDAV Sync Error',
- style: theme.textTheme.titleLarge?.copyWith(
- color: colorScheme.error,
- fontWeight: FontWeight.bold,
- ),
- ),
- const SizedBox(height: 8),
- Text(
- provider.errorMessage!,
- textAlign: TextAlign.center,
- style: theme.textTheme.bodyMedium?.copyWith(
- color: colorScheme.onSurfaceVariant,
- ),
- ),
- const SizedBox(height: 20),
- FilledButton.icon(
- onPressed: provider.refreshData,
- icon: const Icon(Icons.refresh_rounded),
- label: const Text('Retry Connection'),
- ),
- ],
- ),
- ),
- ),
- )
- else if (provider.items.isEmpty)
- SliverFillRemaining(
- child: Center(
+ // Files List / Grid
+ if (provider.isLoading)
+ const SliverFillRemaining(
+ hasScrollBody: false,
+ child: Center(child: CircularProgressIndicator()),
+ )
+ else if (provider.errorMessage != null)
+ SliverFillRemaining(
+ hasScrollBody: false,
+ child: Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
- Icons.folder_open_rounded,
+ Icons.error_outline_rounded,
size: 64,
- color: colorScheme.outlineVariant,
+ color: colorScheme.error,
),
- const SizedBox(height: 12),
+ const SizedBox(height: 16),
Text(
- 'Folder is empty',
- style: theme.textTheme.titleMedium?.copyWith(
+ 'WebDAV Sync Error',
+ style: theme.textTheme.titleLarge?.copyWith(
+ color: colorScheme.error,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ provider.errorMessage!,
+ textAlign: TextAlign.center,
+ style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
+ const SizedBox(height: 20),
+ FilledButton.icon(
+ onPressed: provider.refreshData,
+ icon: const Icon(Icons.refresh_rounded),
+ label: const Text('Retry Connection'),
+ ),
],
),
),
- )
- else if (provider.isGridView)
- SliverPadding(
- padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
- sliver: SliverGrid(
- gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
- crossAxisCount: 2,
- childAspectRatio: 1.1,
- crossAxisSpacing: 12,
- mainAxisSpacing: 12,
- ),
- delegate: SliverChildBuilderDelegate(
- (context, index) {
- final item = provider.items[index];
- return _buildGridCard(context, item, provider);
- },
- childCount: provider.items.length,
- ),
- ),
- )
- else
- SliverPadding(
- padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
- sliver: SliverList(
- delegate: SliverChildBuilderDelegate(
- (context, index) {
- final item = provider.items[index];
- return _buildListTile(context, item, provider);
- },
- childCount: provider.items.length,
- ),
+ ),
+ )
+ else if (provider.items.isEmpty)
+ SliverFillRemaining(
+ hasScrollBody: false,
+ child: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.folder_open_rounded,
+ size: 64,
+ color: colorScheme.outlineVariant,
+ ),
+ const SizedBox(height: 12),
+ Text(
+ 'Folder is empty',
+ style: theme.textTheme.titleMedium?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
),
),
-
- const SliverToBoxAdapter(
- child: SizedBox(height: 100),
+ )
+ else if (provider.isGridView)
+ SliverPadding(
+ key: const ValueKey('files-grid'),
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
+ sliver: SliverGrid(
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: 2,
+ childAspectRatio: 1.1,
+ crossAxisSpacing: 12,
+ mainAxisSpacing: 12,
+ ),
+ delegate: SliverChildBuilderDelegate((context, index) {
+ final item = provider.items[index];
+ return _buildGridCard(context, item, provider);
+ }, childCount: provider.items.length),
+ ),
+ )
+ else
+ SliverPadding(
+ key: const ValueKey('files-list'),
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
+ sliver: SliverList(
+ delegate: SliverChildBuilderDelegate((context, index) {
+ final item = provider.items[index];
+ return _buildListTile(context, item, provider);
+ }, childCount: provider.items.length),
+ ),
),
- ],
+
+ // Fixed clearance so the last item isn't hidden behind the floating
+ // nav bar, regardless of list length.
+ const SliverToBoxAdapter(child: SizedBox(height: 100)),
+ // For a short list this stretches the white card's background down to
+ // the screen edge (matching the empty/loading/error states, which
+ // already use SliverFillRemaining); for a long list that already fills
+ // the viewport it contributes nothing extra.
+ const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
+ ];
+
+ return PopScope(
+ canPop: provider.pathStack.length <= 1,
+ onPopInvokedWithResult: (didPop, result) {
+ if (!didPop) provider.navigateUp();
+ },
+ child: ColoredBox(
+ color: colorScheme.surfaceContainer,
+ child: NotificationListener(
+ onNotification: _handleScrollNotification,
+ child: CustomScrollView(
+ controller: widget.scrollController,
+ physics: const BouncingScrollPhysics(),
+ slivers: [
+ SliverAppBar(
+ pinned: true,
+ stretch: true,
+ expandedHeight: _headerLocked ? 190 : kToolbarHeight,
+ collapsedHeight: kToolbarHeight,
+ backgroundColor: colorScheme.surfaceContainer,
+ surfaceTintColor: colorScheme.surfaceContainer,
+ scrolledUnderElevation: 0,
+ automaticallyImplyLeading: false,
+ leadingWidth: _headerLocked ? 56 : 160,
+ leading: leadingWidget,
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.add_rounded),
+ tooltip: 'New',
+ onPressed: () => _showCreateMenu(context, provider),
+ ),
+ const ProfileAvatarButton(),
+ ],
+ flexibleSpace: FlexibleSpaceBar(
+ background: _FilesStretchPanel(
+ provider: provider,
+ forceVisible: _headerLocked,
+ ),
+ ),
+ ),
+ DecoratedSliver(
+ decoration: BoxDecoration(
+ color: colorScheme.surface,
+ borderRadius: const BorderRadius.vertical(
+ top: Radius.circular(28),
+ ),
+ ),
+ sliver: SliverMainAxisGroup(slivers: contentSlivers),
+ ),
+ ],
+ ),
+ ),
+ ),
);
}
- Widget _buildListTile(BuildContext context, NextcloudItem item, ServerProvider provider) {
+ Widget _buildListTile(
+ BuildContext context,
+ NextcloudItem item,
+ ServerProvider provider,
+ ) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
@@ -318,24 +640,19 @@ class FilesView extends StatelessWidget {
if (item.isFolder) {
provider.navigateToFolder(item.path);
} else {
- _showFileDetailsSheet(context, item, provider);
+ _openFile(context, item, provider);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
- Container(
- width: 44,
- height: 44,
- decoration: BoxDecoration(
- color: _getIconColor(context, item.type).withValues(alpha: 0.12),
- borderRadius: BorderRadius.circular(12),
- ),
- child: Icon(
- _getItemIcon(item.type),
- color: _getIconColor(context, item.type),
- ),
+ _ItemThumbnail(
+ item: item,
+ provider: provider,
+ size: 44,
+ borderRadius: 12,
+ iconSize: 22,
),
const SizedBox(width: 14),
Expanded(
@@ -365,8 +682,12 @@ class FilesView extends StatelessWidget {
),
IconButton(
icon: Icon(
- item.isFavorite ? Icons.star_rounded : Icons.star_outline_rounded,
- color: item.isFavorite ? Colors.amber.shade700 : colorScheme.outlineVariant,
+ item.isFavorite
+ ? Icons.star_rounded
+ : Icons.star_outline_rounded,
+ color: item.isFavorite
+ ? Colors.amber.shade700
+ : colorScheme.outlineVariant,
size: 20,
),
onPressed: () => provider.toggleItemFavorite(item),
@@ -377,7 +698,8 @@ class FilesView extends StatelessWidget {
color: colorScheme.onSurfaceVariant,
size: 20,
),
- onPressed: () => _showFileDetailsSheet(context, item, provider),
+ onPressed: () =>
+ _showFileDetailsSheet(context, item, provider),
),
],
),
@@ -387,7 +709,11 @@ class FilesView extends StatelessWidget {
);
}
- Widget _buildGridCard(BuildContext context, NextcloudItem item, ServerProvider provider) {
+ Widget _buildGridCard(
+ BuildContext context,
+ NextcloudItem item,
+ ServerProvider provider,
+ ) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
@@ -400,7 +726,7 @@ class FilesView extends StatelessWidget {
if (item.isFolder) {
provider.navigateToFolder(item.path);
} else {
- _showFileDetailsSheet(context, item, provider);
+ _openFile(context, item, provider);
}
},
child: Padding(
@@ -412,22 +738,21 @@ class FilesView extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- Container(
- padding: const EdgeInsets.all(8),
- decoration: BoxDecoration(
- color: _getIconColor(context, item.type).withValues(alpha: 0.12),
- borderRadius: BorderRadius.circular(12),
- ),
- child: Icon(
- _getItemIcon(item.type),
- color: _getIconColor(context, item.type),
- size: 24,
- ),
+ _ItemThumbnail(
+ item: item,
+ provider: provider,
+ size: 40,
+ borderRadius: 12,
+ iconSize: 24,
),
IconButton(
icon: Icon(
- item.isFavorite ? Icons.star_rounded : Icons.star_outline_rounded,
- color: item.isFavorite ? Colors.amber.shade700 : colorScheme.outlineVariant,
+ item.isFavorite
+ ? Icons.star_rounded
+ : Icons.star_outline_rounded,
+ color: item.isFavorite
+ ? Colors.amber.shade700
+ : colorScheme.outlineVariant,
size: 20,
),
onPressed: () => provider.toggleItemFavorite(item),
@@ -463,7 +788,61 @@ class FilesView extends StatelessWidget {
);
}
- void _showFileDetailsSheet(BuildContext context, NextcloudItem item, ServerProvider provider) {
+ void _openFile(
+ BuildContext context,
+ NextcloudItem item,
+ ServerProvider provider,
+ ) {
+ Navigator.push(
+ context,
+ FileViewerScreen.route(item: item, siblings: provider.items),
+ );
+ }
+
+ Future _downloadItem(
+ BuildContext context,
+ ServerProvider provider,
+ NextcloudItem item,
+ ) async {
+ final messenger = ScaffoldMessenger.of(context);
+ messenger.showSnackBar(
+ SnackBar(
+ content: Text('Downloading ${item.name}...'),
+ behavior: SnackBarBehavior.floating,
+ ),
+ );
+ try {
+ final tempDir = await getTemporaryDirectory();
+ final tempPath = p.join(tempDir.path, item.name);
+ await provider.service!.downloadToFile(item.path, tempPath);
+ final ext = p.extension(item.name).replaceFirst('.', '');
+ final baseName = p.basenameWithoutExtension(item.name);
+ await FileSaver.instance.saveFile(
+ name: baseName,
+ filePath: tempPath,
+ ext: ext,
+ );
+ messenger.showSnackBar(
+ SnackBar(
+ content: Text('Saved ${item.name} to Downloads'),
+ behavior: SnackBarBehavior.floating,
+ ),
+ );
+ } catch (e) {
+ messenger.showSnackBar(
+ SnackBar(
+ content: Text('Download failed: $e'),
+ behavior: SnackBarBehavior.floating,
+ ),
+ );
+ }
+ }
+
+ void _showFileDetailsSheet(
+ BuildContext context,
+ NextcloudItem item,
+ ServerProvider provider,
+ ) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
@@ -496,7 +875,10 @@ class FilesView extends StatelessWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
- color: _getIconColor(context, item.type).withValues(alpha: 0.12),
+ color: _getIconColor(
+ context,
+ item.type,
+ ).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(16),
),
child: Icon(
@@ -531,27 +913,61 @@ class FilesView extends StatelessWidget {
const SizedBox(height: 24),
const Divider(height: 1),
const SizedBox(height: 16),
+ if (!item.isFolder) ...[
+ ListTile(
+ leading: const Icon(Icons.open_in_new_rounded),
+ title: const Text('Open'),
+ onTap: () {
+ Navigator.pop(context);
+ _openFile(context, item, provider);
+ },
+ ),
+ ListTile(
+ leading: const Icon(Icons.download_rounded),
+ title: const Text('Download'),
+ onTap: () {
+ Navigator.pop(context);
+ _downloadItem(context, provider, item);
+ },
+ ),
+ ],
ListTile(
leading: Icon(
- item.isFavorite ? Icons.star_rounded : Icons.star_outline_rounded,
+ item.isFavorite
+ ? Icons.star_rounded
+ : Icons.star_outline_rounded,
color: item.isFavorite ? Colors.amber.shade700 : null,
),
- title: Text(item.isFavorite ? 'Remove from Favorites' : 'Add to Favorites'),
+ title: Text(
+ item.isFavorite
+ ? 'Remove from Favorites'
+ : 'Add to Favorites',
+ ),
onTap: () {
provider.toggleItemFavorite(item);
Navigator.pop(context);
},
),
ListTile(
- leading: Icon(Icons.delete_outline_rounded, color: colorScheme.error),
- title: Text('Delete from Server', style: TextStyle(color: colorScheme.error)),
+ leading: Icon(
+ Icons.delete_outline_rounded,
+ color: colorScheme.error,
+ ),
+ title: Text(
+ 'Delete from Server',
+ style: TextStyle(color: colorScheme.error),
+ ),
onTap: () async {
Navigator.pop(context);
final success = await provider.deleteItem(item.path);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
- content: Text(success ? 'Deleted ${item.name}' : 'Failed to delete ${item.name}'),
+ content: Text(
+ success
+ ? 'Deleted ${item.name}'
+ : 'Failed to delete ${item.name}',
+ ),
behavior: SnackBarBehavior.floating,
),
);
@@ -565,6 +981,54 @@ class FilesView extends StatelessWidget {
);
}
+ void _showCreateMenu(BuildContext context, ServerProvider provider) {
+ final colorScheme = Theme.of(context).colorScheme;
+ showModalBottomSheet(
+ context: context,
+ backgroundColor: colorScheme.surfaceContainerHigh,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
+ ),
+ builder: (sheetContext) {
+ return SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: 36,
+ height: 4,
+ margin: const EdgeInsets.only(bottom: 12),
+ decoration: BoxDecoration(
+ color: colorScheme.outlineVariant,
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ ListTile(
+ leading: const Icon(Icons.upload_file_rounded),
+ title: const Text('Upload File'),
+ onTap: () {
+ Navigator.pop(sheetContext);
+ _pickAndUploadFile(context, provider);
+ },
+ ),
+ ListTile(
+ leading: const Icon(Icons.create_new_folder_outlined),
+ title: const Text('New Folder'),
+ onTap: () {
+ Navigator.pop(sheetContext);
+ _showCreateFolderDialog(context, provider);
+ },
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ }
+
void _showCreateFolderDialog(BuildContext context, ServerProvider provider) {
final controller = TextEditingController();
@@ -594,7 +1058,11 @@ class FilesView extends StatelessWidget {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
- content: Text(success ? 'Created folder $name' : 'Failed to create folder'),
+ content: Text(
+ success
+ ? 'Created folder $name'
+ : 'Failed to create folder',
+ ),
behavior: SnackBarBehavior.floating,
),
);
@@ -609,61 +1077,199 @@ class FilesView extends StatelessWidget {
);
}
- void _showUploadDialog(BuildContext context, ServerProvider provider) {
- final nameController = TextEditingController();
- final contentController = TextEditingController();
+ Future _pickAndUploadFile(
+ BuildContext context,
+ ServerProvider provider,
+ ) async {
+ final result = await FilePicker.pickFiles();
+ if (result.isEmpty) return;
+ final picked = result.first;
+ if (picked.path == null) return;
+
+ final progress = ValueNotifier(0);
+ if (!context.mounted) return;
showDialog(
context: context,
- builder: (context) {
+ barrierDismissible: false,
+ builder: (dialogContext) {
return AlertDialog(
- title: const Text('Upload Text File'),
- content: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- TextField(
- controller: nameController,
- decoration: const InputDecoration(
- labelText: 'File Name',
- hintText: 'notes.txt',
- ),
- ),
- const SizedBox(height: 12),
- TextField(
- controller: contentController,
- maxLines: 3,
- decoration: const InputDecoration(
- labelText: 'File Content',
- hintText: 'Enter text to upload...',
- ),
- ),
- ],
+ title: Text('Uploading ${picked.name}'),
+ content: ValueListenableBuilder(
+ valueListenable: progress,
+ builder: (context, value, _) =>
+ LinearProgressIndicator(value: value),
),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(context),
- child: const Text('Cancel'),
+ );
+ },
+ );
+
+ bool success = false;
+ try {
+ success = await provider.uploadFileFromPath(
+ picked.name,
+ picked.path!,
+ onProgress: (sent, total) {
+ if (total > 0) progress.value = sent / total;
+ },
+ );
+ } catch (_) {
+ success = false;
+ }
+
+ if (context.mounted) {
+ Navigator.pop(context); // close progress dialog
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ success
+ ? 'Uploaded ${picked.name} to Nextcloud'
+ : 'Failed to upload ${picked.name}',
+ ),
+ behavior: SnackBarBehavior.floating,
+ ),
+ );
+ }
+ }
+}
+
+/// A compact, always-visible summary of the same status shown by
+/// [_FilesStretchPanel] — tapping it is a discoverable shortcut for pulling
+/// the header down to expand that same "Synced" view.
+class _SyncStatusChip extends StatelessWidget {
+ final ServerProvider provider;
+ final VoidCallback onTap;
+
+ const _SyncStatusChip({required this.provider, required this.onTap});
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+ final quota = provider.quota;
+
+ return Padding(
+ padding: const EdgeInsets.only(left: 12),
+ child: Material(
+ color: Colors.transparent,
+ shape: const StadiumBorder(),
+ child: InkWell(
+ onTap: onTap,
+ customBorder: const StadiumBorder(),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ provider.isLoading
+ ? Icons.cloud_sync_rounded
+ : Icons.cloud_done_outlined,
+ color: colorScheme.primary,
+ size: 18,
+ ),
+ const SizedBox(width: 6),
+ Text(
+ quota != null ? _formatBytes(quota.usedBytes) : 'Sync',
+ style: theme.textTheme.labelMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
),
- FilledButton(
- onPressed: () async {
- final name = nameController.text.trim();
- final text = contentController.text;
- if (name.isNotEmpty) {
- Navigator.pop(context);
- final bytes = Uint8List.fromList(text.codeUnits);
- final success = await provider.uploadFile(name, bytes);
- if (context.mounted) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(success ? 'Uploaded $name to Nextcloud' : 'Failed to upload $name'),
- behavior: SnackBarBehavior.floating,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// Revealed by pulling the "Files" header down past its normal height
+/// (Google Photos' "pull to see backup status" pattern). Shows storage quota;
+/// [_FilesViewState] decides via [forceVisible] whether it stays locked open.
+class _FilesStretchPanel extends StatelessWidget {
+ final ServerProvider provider;
+
+ /// Keeps the panel fully shown even without live overscroll — used once
+ /// the header has "locked" open after a deliberate pull-down.
+ final bool forceVisible;
+
+ const _FilesStretchPanel({required this.provider, this.forceVisible = false});
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+ final topInset = MediaQuery.of(context).padding.top;
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final settings = context
+ .dependOnInheritedWidgetOfExactType();
+ final maxExtent = settings?.maxExtent ?? constraints.maxHeight;
+ final stretch = (constraints.maxHeight - maxExtent).clamp(0.0, 80.0);
+ // Reaches 1.0 (title fully hidden) at 50px of pull — comfortably
+ // before the 100px stretchTriggerOffset that locks the header open.
+ final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0);
+ final quota = provider.quota;
+
+ return Stack(
+ children: [
+ if (progress > 0)
+ // Anchored below the toolbar row (icons), never the bottom,
+ // so it can never share space with the title above.
+ Positioned(
+ left: 20,
+ right: 20,
+ top: topInset + kToolbarHeight + 4,
+ child: Opacity(
+ opacity: progress,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ provider.isLoading ? 'Syncing…' : 'Synced',
+ style: theme.textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
),
- );
- }
- }
- },
- child: const Text('Upload'),
- ),
+ const SizedBox(height: 10),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 12,
+ ),
+ decoration: BoxDecoration(
+ color: colorScheme.surfaceContainerHigh,
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.cloud_done_outlined,
+ color: colorScheme.primary,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ quota != null
+ ? (quota.totalBytes > 0
+ ? '${_formatBytes(quota.usedBytes)} of ${_formatBytes(quota.totalBytes)} used'
+ : '${_formatBytes(quota.usedBytes)} used')
+ : 'Pull to refresh',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
],
);
},
diff --git a/lib/views/login_view.dart b/lib/views/login_view.dart
index 3f421e4..5583a23 100644
--- a/lib/views/login_view.dart
+++ b/lib/views/login_view.dart
@@ -11,40 +11,18 @@ class LoginView extends StatefulWidget {
class _LoginViewState extends State {
final _formKey = GlobalKey();
- final _urlController = TextEditingController(text: 'https://');
- final _userController = TextEditingController();
- final _passController = TextEditingController();
- bool _obscurePassword = true;
+ final _urlController = TextEditingController();
@override
void dispose() {
_urlController.dispose();
- _userController.dispose();
- _passController.dispose();
super.dispose();
}
- void _handleLogin() async {
+ void _handleContinue() {
if (!_formKey.currentState!.validate()) return;
FocusScope.of(context).unfocus();
-
- final provider = context.read();
- final success = await provider.login(
- serverUrl: _urlController.text.trim(),
- username: _userController.text.trim(),
- password: _passController.text,
- );
-
- if (!success && mounted) {
- final error = provider.errorMessage ?? 'Failed to connect to Nextcloud server.';
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(error),
- backgroundColor: Theme.of(context).colorScheme.error,
- behavior: SnackBarBehavior.floating,
- ),
- );
- }
+ context.read().startLoginFlow(_urlController.text.trim());
}
@override
@@ -53,6 +31,10 @@ class _LoginViewState extends State {
final colorScheme = theme.colorScheme;
final provider = context.watch();
+ final isAwaitingBrowser =
+ provider.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
+ final isInitiating = provider.loginFlowStatus == LoginFlowStatus.initiating;
+
return Scaffold(
body: SafeArea(
child: Center(
@@ -61,193 +43,23 @@ class _LoginViewState extends State {
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
- child: Form(
- key: _formKey,
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- // Header Logo & Branding
- Center(
- child: Container(
- width: 80,
- height: 80,
- decoration: BoxDecoration(
- color: colorScheme.primaryContainer,
- shape: BoxShape.circle,
- ),
- child: Icon(
- Icons.cloud_outlined,
- size: 44,
- color: colorScheme.onPrimaryContainer,
- ),
- ),
+ child: isAwaitingBrowser
+ ? _WaitingForBrowser(
+ onCancel: () => provider.cancelLoginFlow(),
+ onReopenBrowser: provider.reopenLoginBrowser,
+ )
+ : _ServerForm(
+ formKey: _formKey,
+ urlController: _urlController,
+ isLoading: isInitiating || provider.isLoading,
+ errorMessage:
+ provider.loginFlowStatus == LoginFlowStatus.error
+ ? provider.errorMessage
+ : null,
+ onContinue: _handleContinue,
+ theme: theme,
+ colorScheme: colorScheme,
),
- const SizedBox(height: 24),
- Text(
- 'Nextcloud Client',
- textAlign: TextAlign.center,
- style: theme.textTheme.headlineMedium?.copyWith(
- fontWeight: FontWeight.w800,
- letterSpacing: -0.5,
- ),
- ),
- const SizedBox(height: 6),
- Text(
- 'Log in to your selfhosted Nextcloud server',
- textAlign: TextAlign.center,
- style: theme.textTheme.bodyMedium?.copyWith(
- color: colorScheme.onSurfaceVariant,
- ),
- ),
- const SizedBox(height: 36),
-
- // Error Box if any
- if (provider.errorMessage != null)
- Container(
- margin: const EdgeInsets.only(bottom: 20),
- padding: const EdgeInsets.all(14),
- decoration: BoxDecoration(
- color: colorScheme.errorContainer,
- borderRadius: BorderRadius.circular(16),
- ),
- child: Row(
- children: [
- Icon(
- Icons.error_outline_rounded,
- color: colorScheme.onErrorContainer,
- size: 20,
- ),
- const SizedBox(width: 12),
- Expanded(
- child: Text(
- provider.errorMessage!,
- style: theme.textTheme.bodySmall?.copyWith(
- color: colorScheme.onErrorContainer,
- fontWeight: FontWeight.w500,
- ),
- ),
- ),
- ],
- ),
- ),
-
- // Server URL Input
- TextFormField(
- controller: _urlController,
- keyboardType: TextInputType.url,
- decoration: InputDecoration(
- labelText: 'Server Address',
- hintText: 'https://cloud.example.com',
- prefixIcon: const Icon(Icons.dns_outlined),
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(16),
- ),
- filled: true,
- fillColor: colorScheme.surfaceContainerLow,
- ),
- validator: (val) {
- if (val == null || val.trim().isEmpty || val == 'https://') {
- return 'Please enter your Nextcloud server URL';
- }
- return null;
- },
- ),
- const SizedBox(height: 16),
-
- // Username Input
- TextFormField(
- controller: _userController,
- decoration: InputDecoration(
- labelText: 'Username',
- prefixIcon: const Icon(Icons.person_outline_rounded),
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(16),
- ),
- filled: true,
- fillColor: colorScheme.surfaceContainerLow,
- ),
- validator: (val) {
- if (val == null || val.trim().isEmpty) {
- return 'Please enter your username';
- }
- return null;
- },
- ),
- const SizedBox(height: 16),
-
- // Password / App Password Input
- TextFormField(
- controller: _passController,
- obscureText: _obscurePassword,
- decoration: InputDecoration(
- labelText: 'Password or App Password',
- prefixIcon: const Icon(Icons.lock_outline_rounded),
- suffixIcon: IconButton(
- icon: Icon(
- _obscurePassword ? Icons.visibility_off_outlined : Icons.visibility_outlined,
- ),
- onPressed: () {
- setState(() {
- _obscurePassword = !_obscurePassword;
- });
- },
- ),
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(16),
- ),
- filled: true,
- fillColor: colorScheme.surfaceContainerLow,
- ),
- validator: (val) {
- if (val == null || val.isEmpty) {
- return 'Please enter your password or app password';
- }
- return null;
- },
- ),
- const SizedBox(height: 28),
-
- // Login Button
- SizedBox(
- height: 54,
- child: FilledButton(
- onPressed: provider.isLoading ? null : _handleLogin,
- style: FilledButton.styleFrom(
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(27),
- ),
- ),
- child: provider.isLoading
- ? SizedBox(
- width: 24,
- height: 24,
- child: CircularProgressIndicator(
- strokeWidth: 2.5,
- color: colorScheme.onPrimary,
- ),
- )
- : const Text(
- 'Log In',
- style: TextStyle(
- fontSize: 16,
- fontWeight: FontWeight.bold,
- ),
- ),
- ),
- ),
- const SizedBox(height: 24),
- Text(
- 'Supports standard WebDAV and OCS APIs for all self-hosted Nextcloud Hub installations.',
- textAlign: TextAlign.center,
- style: theme.textTheme.bodySmall?.copyWith(
- color: colorScheme.onSurfaceVariant,
- fontSize: 11,
- ),
- ),
- ],
- ),
- ),
),
),
),
@@ -255,3 +67,203 @@ class _LoginViewState extends State {
);
}
}
+
+class _ServerForm extends StatelessWidget {
+ final GlobalKey formKey;
+ final TextEditingController urlController;
+ final bool isLoading;
+ final String? errorMessage;
+ final VoidCallback onContinue;
+ final ThemeData theme;
+ final ColorScheme colorScheme;
+
+ const _ServerForm({
+ required this.formKey,
+ required this.urlController,
+ required this.isLoading,
+ required this.errorMessage,
+ required this.onContinue,
+ required this.theme,
+ required this.colorScheme,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Form(
+ key: formKey,
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Center(
+ child: Container(
+ width: 80,
+ height: 80,
+ decoration: BoxDecoration(
+ color: colorScheme.primaryContainer,
+ shape: BoxShape.circle,
+ ),
+ child: Icon(
+ Icons.cloud_outlined,
+ size: 44,
+ color: colorScheme.onPrimaryContainer,
+ ),
+ ),
+ ),
+ const SizedBox(height: 24),
+ Text(
+ 'Noo',
+ textAlign: TextAlign.center,
+ style: theme.textTheme.headlineMedium?.copyWith(
+ fontWeight: FontWeight.w800,
+ letterSpacing: -0.5,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ 'Connect to your self-hosted server',
+ textAlign: TextAlign.center,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 36),
+ if (errorMessage != null)
+ Container(
+ margin: const EdgeInsets.only(bottom: 20),
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ color: colorScheme.errorContainer,
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.error_outline_rounded,
+ color: colorScheme.onErrorContainer,
+ size: 20,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ errorMessage!,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: colorScheme.onErrorContainer,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ TextFormField(
+ controller: urlController,
+ keyboardType: TextInputType.url,
+ autofillHints: const [AutofillHints.url],
+ decoration: InputDecoration(
+ labelText: 'Server Address',
+ hintText: 'cloud.example.com',
+ prefixIcon: const Icon(Icons.dns_outlined),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(16),
+ ),
+ filled: true,
+ fillColor: colorScheme.surfaceContainerLow,
+ ),
+ validator: (val) {
+ if (val == null || val.trim().isEmpty) {
+ return 'Please enter your Nextcloud server address';
+ }
+ return null;
+ },
+ onFieldSubmitted: (_) => onContinue(),
+ ),
+ const SizedBox(height: 28),
+ SizedBox(
+ height: 54,
+ child: FilledButton(
+ onPressed: isLoading ? null : onContinue,
+ style: FilledButton.styleFrom(
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(27),
+ ),
+ ),
+ child: isLoading
+ ? SizedBox(
+ width: 24,
+ height: 24,
+ child: CircularProgressIndicator(
+ strokeWidth: 2.5,
+ color: colorScheme.onPrimary,
+ ),
+ )
+ : const Text(
+ 'Continue',
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: 24),
+ Text(
+ "You'll finish signing in through your browser. This app never sees your password.",
+ textAlign: TextAlign.center,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ fontSize: 11,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _WaitingForBrowser extends StatelessWidget {
+ final VoidCallback onCancel;
+ final VoidCallback onReopenBrowser;
+
+ const _WaitingForBrowser({
+ required this.onCancel,
+ required this.onReopenBrowser,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ CircularProgressIndicator(color: colorScheme.primary),
+ const SizedBox(height: 28),
+ Text(
+ 'Waiting for you to sign in',
+ textAlign: TextAlign.center,
+ style: theme.textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Complete the login in the browser window that just opened, then come back here.',
+ textAlign: TextAlign.center,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 32),
+ OutlinedButton.icon(
+ onPressed: onReopenBrowser,
+ icon: const Icon(Icons.open_in_browser_rounded),
+ label: const Text('Reopen browser'),
+ ),
+ const SizedBox(height: 12),
+ TextButton(onPressed: onCancel, child: const Text('Cancel')),
+ ],
+ );
+ }
+}
diff --git a/lib/views/photos_view.dart b/lib/views/photos_view.dart
index ba3ae90..b7654ed 100644
--- a/lib/views/photos_view.dart
+++ b/lib/views/photos_view.dart
@@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
-import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/nextcloud_item.dart';
import '../providers/server_provider.dart';
+import '../widgets/profile_avatar_button.dart';
+import 'file_viewer_screen.dart';
class PhotosView extends StatelessWidget {
final ScrollController scrollController;
@@ -20,29 +21,16 @@ class PhotosView extends StatelessWidget {
controller: scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
- SliverToBoxAdapter(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- 'Photos & Media',
- style: theme.textTheme.headlineMedium?.copyWith(
- fontWeight: FontWeight.w700,
- ),
- ),
- const SizedBox(height: 4),
- Text(
- '${photos.length} cloud photos & video streams',
- style: theme.textTheme.bodyMedium?.copyWith(
- color: colorScheme.onSurfaceVariant,
- ),
- ),
- ],
- ),
- ),
+ SliverAppBar(
+ pinned: true,
+ expandedHeight: kToolbarHeight,
+ collapsedHeight: kToolbarHeight,
+ backgroundColor: colorScheme.surface,
+ surfaceTintColor: colorScheme.surface,
+ scrolledUnderElevation: 0,
+ actions: const [ProfileAvatarButton()],
),
+ const SliverToBoxAdapter(child: SizedBox(height: 8)),
if (photos.isEmpty)
SliverFillRemaining(
@@ -76,24 +64,23 @@ class PhotosView extends StatelessWidget {
mainAxisSpacing: 8,
childAspectRatio: 1.0,
),
- delegate: SliverChildBuilderDelegate(
- (context, index) {
- final photo = photos[index];
- return _buildPhotoTile(context, photo, provider);
- },
- childCount: photos.length,
- ),
+ delegate: SliverChildBuilderDelegate((context, index) {
+ final photo = photos[index];
+ return _buildPhotoTile(context, photo, provider);
+ }, childCount: photos.length),
),
),
- const SliverToBoxAdapter(
- child: SizedBox(height: 100),
- ),
+ const SliverToBoxAdapter(child: SizedBox(height: 100)),
],
);
}
- Widget _buildPhotoTile(BuildContext context, NextcloudItem photo, ServerProvider provider) {
+ Widget _buildPhotoTile(
+ BuildContext context,
+ NextcloudItem photo,
+ ServerProvider provider,
+ ) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
@@ -109,10 +96,27 @@ class PhotosView extends StatelessWidget {
fit: StackFit.expand,
children: [
photo.previewUrl != null
- ? Image.network(
- photo.previewUrl!,
- fit: BoxFit.cover,
- errorBuilder: (ctx, err, stack) => _buildFallbackTile(context, photo),
+ ? LayoutBuilder(
+ builder: (context, constraints) {
+ // Decode at the tile's actual rendered size rather
+ // than the full 500x500 preview the server returns —
+ // cheaper to decode/cache while scrolling a grid.
+ final cachePixels =
+ (constraints.maxWidth *
+ MediaQuery.of(context).devicePixelRatio)
+ .round();
+ return Image.network(
+ photo.previewUrl!,
+ headers: provider.service?.authHeaders,
+ fit: BoxFit.cover,
+ cacheWidth: cachePixels,
+ cacheHeight: cachePixels,
+ filterQuality: FilterQuality.low,
+ gaplessPlayback: true,
+ errorBuilder: (ctx, err, stack) =>
+ _buildFallbackTile(context, photo),
+ );
+ },
)
: _buildFallbackTile(context, photo),
if (photo.type == NextcloudItemType.video)
@@ -136,11 +140,7 @@ class PhotosView extends StatelessWidget {
const Positioned(
top: 8,
right: 8,
- child: Icon(
- Icons.star_rounded,
- color: Colors.amber,
- size: 18,
- ),
+ child: Icon(Icons.star_rounded, color: Colors.amber, size: 18),
),
],
),
@@ -164,123 +164,14 @@ class PhotosView extends StatelessWidget {
);
}
- void _openLightbox(BuildContext context, NextcloudItem photo, ServerProvider provider) {
- showDialog(
- context: context,
- barrierColor: Colors.black.withValues(alpha: 0.92),
- builder: (context) {
- return Dialog.fullscreen(
- backgroundColor: Colors.transparent,
- child: Stack(
- children: [
- InteractiveViewer(
- minScale: 0.8,
- maxScale: 4.0,
- child: Center(
- child: photo.previewUrl != null
- ? Image.network(
- photo.previewUrl!,
- fit: BoxFit.contain,
- )
- : Icon(
- photo.type == NextcloudItemType.video
- ? Icons.movie_rounded
- : Icons.image_rounded,
- size: 120,
- color: Colors.white70,
- ),
- ),
- ),
- Positioned(
- top: 24,
- left: 16,
- right: 16,
- child: SafeArea(
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- IconButton.filledTonal(
- onPressed: () => Navigator.pop(context),
- icon: const Icon(Icons.close_rounded, color: Colors.white),
- style: IconButton.styleFrom(
- backgroundColor: Colors.black.withValues(alpha: 0.5),
- ),
- ),
- Row(
- children: [
- IconButton.filledTonal(
- onPressed: () {
- provider.toggleItemFavorite(photo);
- },
- icon: Icon(
- photo.isFavorite ? Icons.star_rounded : Icons.star_outline_rounded,
- color: photo.isFavorite ? Colors.amber : Colors.white,
- ),
- style: IconButton.styleFrom(
- backgroundColor: Colors.black.withValues(alpha: 0.5),
- ),
- ),
- const SizedBox(width: 8),
- IconButton.filledTonal(
- onPressed: () {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text('Downloading full resolution ${photo.name}...'),
- behavior: SnackBarBehavior.floating,
- ),
- );
- },
- icon: const Icon(Icons.download_rounded, color: Colors.white),
- style: IconButton.styleFrom(
- backgroundColor: Colors.black.withValues(alpha: 0.5),
- ),
- ),
- ],
- ),
- ],
- ),
- ),
- ),
- Positioned(
- bottom: 32,
- left: 24,
- right: 24,
- child: SafeArea(
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
- decoration: BoxDecoration(
- color: Colors.black.withValues(alpha: 0.65),
- borderRadius: BorderRadius.circular(24),
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(
- photo.name,
- style: const TextStyle(
- color: Colors.white,
- fontWeight: FontWeight.w600,
- fontSize: 15,
- ),
- ),
- const SizedBox(height: 4),
- Text(
- '${DateFormat.yMMMMd().format(photo.lastModified)} • Nextcloud Photos',
- style: const TextStyle(
- color: Colors.white70,
- fontSize: 12,
- ),
- ),
- ],
- ),
- ),
- ),
- ),
- ],
- ),
- );
- },
+ void _openLightbox(
+ BuildContext context,
+ NextcloudItem photo,
+ ServerProvider provider,
+ ) {
+ Navigator.push(
+ context,
+ FileViewerScreen.route(item: photo, siblings: provider.photoItems),
);
}
}
diff --git a/lib/views/search_view.dart b/lib/views/search_view.dart
new file mode 100644
index 0000000..9c3bc12
--- /dev/null
+++ b/lib/views/search_view.dart
@@ -0,0 +1,201 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+import 'package:provider/provider.dart';
+import '../models/nextcloud_item.dart';
+import '../providers/server_provider.dart';
+import 'file_viewer_screen.dart';
+
+class SearchView extends StatefulWidget {
+ const SearchView({super.key});
+
+ @override
+ State createState() => _SearchViewState();
+}
+
+class _SearchViewState extends State {
+ final _controller = TextEditingController();
+ final _focusNode = FocusNode();
+ Timer? _debounce;
+
+ List _results = [];
+ bool _isSearching = false;
+ String? _error;
+ bool _hasSearched = false;
+
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addPostFrameCallback(
+ (_) => _focusNode.requestFocus(),
+ );
+ }
+
+ @override
+ void dispose() {
+ _debounce?.cancel();
+ _controller.dispose();
+ _focusNode.dispose();
+ super.dispose();
+ }
+
+ void _onChanged(String query) {
+ _debounce?.cancel();
+ if (query.trim().isEmpty) {
+ setState(() {
+ _results = [];
+ _hasSearched = false;
+ _error = null;
+ });
+ return;
+ }
+ _debounce = Timer(
+ const Duration(milliseconds: 500),
+ () => _runSearch(query),
+ );
+ }
+
+ Future _runSearch(String query) async {
+ final provider = context.read();
+ setState(() {
+ _isSearching = true;
+ _error = null;
+ });
+ try {
+ final results = await provider.searchFiles(query);
+ if (!mounted) return;
+ setState(() {
+ _results = results;
+ _hasSearched = true;
+ _isSearching = false;
+ });
+ } catch (e) {
+ if (!mounted) return;
+ setState(() {
+ _error = e.toString().replaceAll('Exception: ', '');
+ _isSearching = false;
+ _hasSearched = true;
+ });
+ }
+ }
+
+ IconData _iconFor(NextcloudItemType type) {
+ switch (type) {
+ case NextcloudItemType.folder:
+ return Icons.folder_rounded;
+ case NextcloudItemType.image:
+ return Icons.image_rounded;
+ case NextcloudItemType.video:
+ return Icons.movie_rounded;
+ case NextcloudItemType.audio:
+ return Icons.audiotrack_rounded;
+ case NextcloudItemType.document:
+ return Icons.description_rounded;
+ case NextcloudItemType.archive:
+ return Icons.folder_zip_rounded;
+ case NextcloudItemType.file:
+ return Icons.insert_drive_file_rounded;
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+
+ return Scaffold(
+ appBar: AppBar(
+ titleSpacing: 0,
+ title: TextField(
+ controller: _controller,
+ focusNode: _focusNode,
+ onChanged: _onChanged,
+ decoration: const InputDecoration(
+ hintText: 'Search your files...',
+ border: InputBorder.none,
+ ),
+ style: theme.textTheme.titleMedium,
+ ),
+ actions: [
+ if (_controller.text.isNotEmpty)
+ IconButton(
+ icon: const Icon(Icons.close_rounded),
+ onPressed: () {
+ _controller.clear();
+ _onChanged('');
+ },
+ ),
+ ],
+ ),
+ body: _buildBody(context, colorScheme, theme),
+ );
+ }
+
+ Widget _buildBody(
+ BuildContext context,
+ ColorScheme colorScheme,
+ ThemeData theme,
+ ) {
+ if (_isSearching) {
+ return const Center(child: CircularProgressIndicator());
+ }
+ if (_error != null) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Text(
+ _error!,
+ textAlign: TextAlign.center,
+ style: TextStyle(color: colorScheme.error),
+ ),
+ ),
+ );
+ }
+ if (!_hasSearched) {
+ return Center(
+ child: Text(
+ 'Search across your whole Nextcloud',
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ );
+ }
+ if (_results.isEmpty) {
+ return Center(
+ child: Text(
+ 'No results found',
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ );
+ }
+ return ListView.builder(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ itemCount: _results.length,
+ itemBuilder: (context, index) {
+ final item = _results[index];
+ return ListTile(
+ leading: Icon(_iconFor(item.type), color: colorScheme.primary),
+ title: Text(item.name, maxLines: 1, overflow: TextOverflow.ellipsis),
+ subtitle: Text(
+ item.isFolder
+ ? item.path
+ : DateFormat.yMMMd().format(item.lastModified),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ onTap: () {
+ if (item.isFolder) {
+ context.read().navigateToFolder(item.path);
+ Navigator.pop(context);
+ } else {
+ Navigator.push(context, FileViewerScreen.route(item: item));
+ }
+ },
+ );
+ },
+ );
+ }
+}
diff --git a/lib/widgets/floating_bottom_bar.dart b/lib/widgets/floating_bottom_bar.dart
index ff6e80f..e2fdd2f 100644
--- a/lib/widgets/floating_bottom_bar.dart
+++ b/lib/widgets/floating_bottom_bar.dart
@@ -1,145 +1,140 @@
-import 'dart:ui';
import 'package:flutter/material.dart';
+import 'frosted_glass_container.dart';
class FloatingNavItem {
final String label;
final IconData icon;
- final IconData selectedIcon;
- final String? badge;
- const FloatingNavItem({
- required this.label,
- required this.icon,
- required this.selectedIcon,
- this.badge,
- });
+ const FloatingNavItem({required this.label, required this.icon});
}
+/// A floating pill-shaped bottom navigation bar in the style of Google
+/// Photos' 2026 redesign: unselected destinations collapse to just their
+/// icon, the selected destination expands into an icon+label capsule, and
+/// a detached circular action (search) sits just outside the pill.
class FloatingBottomNavBar extends StatelessWidget {
final int selectedIndex;
final ValueChanged onDestinationSelected;
final List items;
- final bool isVisible;
+ final VoidCallback? onSearchTap;
+ final double opacity;
+ final double blurSigma;
const FloatingBottomNavBar({
super.key,
required this.selectedIndex,
required this.onDestinationSelected,
required this.items,
- this.isVisible = true,
+ this.onSearchTap,
+ this.opacity = 0.55,
+ this.blurSigma = 28,
});
@override
Widget build(BuildContext context) {
- final theme = Theme.of(context);
- final colorScheme = theme.colorScheme;
+ final colorScheme = Theme.of(context).colorScheme;
- return AnimatedSlide(
- duration: const Duration(milliseconds: 300),
- curve: Curves.easeInOutCubic,
- offset: isVisible ? Offset.zero : const Offset(0, 1.5),
- child: AnimatedOpacity(
- duration: const Duration(milliseconds: 250),
- opacity: isVisible ? 1.0 : 0.0,
- child: SafeArea(
- child: Align(
- alignment: Alignment.bottomCenter,
- child: Container(
- margin: const EdgeInsets.only(bottom: 20, left: 24, right: 24),
- constraints: const BoxConstraints(maxWidth: 460),
- height: 68,
- child: ClipRRect(
- borderRadius: BorderRadius.circular(34),
- child: BackdropFilter(
- filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
- decoration: BoxDecoration(
- color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.82),
- borderRadius: BorderRadius.circular(34),
- border: Border.all(
- color: colorScheme.outlineVariant.withValues(alpha: 0.3),
- width: 1,
- ),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withValues(alpha: 0.12),
- blurRadius: 24,
- offset: const Offset(0, 8),
- ),
- ],
- ),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceEvenly,
- children: List.generate(items.length, (index) {
- final item = items[index];
- final isSelected = selectedIndex == index;
-
- return Expanded(
- child: InkWell(
- onTap: () => onDestinationSelected(index),
- borderRadius: BorderRadius.circular(28),
- splashColor: colorScheme.primary.withValues(alpha: 0.12),
- highlightColor: Colors.transparent,
- child: AnimatedContainer(
- duration: const Duration(milliseconds: 250),
- curve: Curves.easeInOutCubic,
- margin: const EdgeInsets.symmetric(horizontal: 4),
- padding: const EdgeInsets.symmetric(vertical: 6),
- decoration: BoxDecoration(
- color: isSelected
- ? colorScheme.primaryContainer
- : Colors.transparent,
- borderRadius: BorderRadius.circular(24),
- ),
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- mainAxisSize: MainAxisSize.min,
- children: [
- AnimatedScale(
- duration: const Duration(milliseconds: 200),
- scale: isSelected ? 1.12 : 1.0,
- child: Icon(
- isSelected ? item.selectedIcon : item.icon,
- size: 22,
- color: isSelected
- ? colorScheme.onPrimaryContainer
- : colorScheme.onSurfaceVariant,
- ),
- ),
- const SizedBox(height: 3),
- AnimatedDefaultTextStyle(
- duration: const Duration(milliseconds: 200),
- style: TextStyle(
- fontSize: 11,
- fontWeight: isSelected
- ? FontWeight.w700
- : FontWeight.w500,
- color: isSelected
- ? colorScheme.onPrimaryContainer
- : colorScheme.onSurfaceVariant,
- letterSpacing: 0.1,
- ),
- child: Text(
- item.label,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- ),
- ),
- ],
- ),
- ),
- ),
- );
- }),
- ),
- ),
- ),
- ),
+ return SafeArea(
+ child: Align(
+ alignment: Alignment.bottomCenter,
+ child: Padding(
+ padding: const EdgeInsets.only(bottom: 20, left: 20, right: 20),
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 480),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _buildPill(context, colorScheme),
+ const SizedBox(width: 10),
+ _buildSearchButton(context, colorScheme),
+ ],
),
),
),
),
);
}
+
+ Widget _buildPill(BuildContext context, ColorScheme colorScheme) {
+ return FrostedGlassContainer(
+ opacity: opacity,
+ blurSigma: blurSigma,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: List.generate(items.length, (index) {
+ final item = items[index];
+ final isSelected = selectedIndex == index;
+
+ return InkWell(
+ onTap: () => onDestinationSelected(index),
+ borderRadius: BorderRadius.circular(26),
+ splashColor: colorScheme.primary.withValues(alpha: 0.12),
+ highlightColor: Colors.transparent,
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 250),
+ curve: Curves.easeInOutCubic,
+ padding: EdgeInsets.symmetric(
+ horizontal: isSelected ? 16 : 12,
+ vertical: 12,
+ ),
+ decoration: BoxDecoration(
+ color: isSelected ? colorScheme.primary : Colors.transparent,
+ borderRadius: BorderRadius.circular(26),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ item.icon,
+ size: 22,
+ color: isSelected
+ ? colorScheme.onPrimary
+ : colorScheme.onSurfaceVariant,
+ ),
+ if (isSelected) ...[
+ const SizedBox(width: 8),
+ Text(
+ item.label,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.w700,
+ color: colorScheme.onPrimary,
+ letterSpacing: 0.1,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ );
+ }),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildSearchButton(BuildContext context, ColorScheme colorScheme) {
+ return FrostedGlassContainer(
+ opacity: opacity,
+ blurSigma: blurSigma,
+ child: SizedBox(
+ width: 52,
+ height: 52,
+ child: InkWell(
+ onTap: onSearchTap,
+ borderRadius: BorderRadius.circular(32),
+ splashColor: colorScheme.primary.withValues(alpha: 0.12),
+ child: Icon(
+ Icons.search_rounded,
+ color: colorScheme.onSurfaceVariant,
+ size: 24,
+ ),
+ ),
+ ),
+ );
+ }
}
diff --git a/lib/widgets/frosted_glass_container.dart b/lib/widgets/frosted_glass_container.dart
new file mode 100644
index 0000000..93e957a
--- /dev/null
+++ b/lib/widgets/frosted_glass_container.dart
@@ -0,0 +1,59 @@
+import 'dart:ui';
+import 'package:flutter/material.dart';
+
+/// The frosted-glass pill background shared by all floating chrome in the
+/// app (bottom nav bar, media viewer action bar): blurred backdrop, a
+/// translucent tonal fill, a faint outline, and a soft drop shadow.
+class FrostedGlassContainer extends StatelessWidget {
+ final Widget child;
+ final double borderRadius;
+
+ /// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an
+ /// explicit value (e.g. from user settings) to make it adjustable.
+ final double blurSigma;
+
+ /// Opacity (0-1) of the tonal fill behind the blur. Defaults to a fixed
+ /// value; pass an explicit value (e.g. from user settings) to make it
+ /// adjustable.
+ final double opacity;
+
+ const FrostedGlassContainer({
+ super.key,
+ required this.child,
+ this.borderRadius = 32,
+ this.blurSigma = 28,
+ this.opacity = 0.55,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return ClipRRect(
+ borderRadius: BorderRadius.circular(borderRadius),
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
+ child: Container(
+ decoration: BoxDecoration(
+ color: colorScheme.surfaceContainerHighest.withValues(
+ alpha: opacity,
+ ),
+ borderRadius: BorderRadius.circular(borderRadius),
+ border: Border.all(
+ color: colorScheme.outlineVariant.withValues(alpha: 0.35),
+ width: 1,
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.14),
+ blurRadius: 24,
+ offset: const Offset(0, 8),
+ ),
+ ],
+ ),
+ child: child,
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/widgets/profile_avatar_button.dart b/lib/widgets/profile_avatar_button.dart
new file mode 100644
index 0000000..3febee4
--- /dev/null
+++ b/lib/widgets/profile_avatar_button.dart
@@ -0,0 +1,45 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import '../providers/server_provider.dart';
+import '../views/account_view.dart';
+
+/// Top-bar avatar button that opens the account screen. Replaces the old
+/// "Account" bottom-nav destination, matching how Google Drive surfaces
+/// account access from a profile picture in the app bar.
+class ProfileAvatarButton extends StatelessWidget {
+ const ProfileAvatarButton({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final provider = context.watch();
+ final colorScheme = Theme.of(context).colorScheme;
+ final initial = provider.username.isNotEmpty
+ ? provider.username[0].toUpperCase()
+ : '?';
+
+ return Padding(
+ padding: const EdgeInsets.only(right: 8),
+ child: IconButton(
+ tooltip: 'Account',
+ onPressed: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(builder: (_) => const AccountView()),
+ );
+ },
+ icon: CircleAvatar(
+ radius: 17,
+ backgroundColor: colorScheme.primary,
+ child: Text(
+ initial,
+ style: TextStyle(
+ color: colorScheme.onPrimary,
+ fontWeight: FontWeight.bold,
+ fontSize: 14,
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/widgets/segmented_icon_toggle.dart b/lib/widgets/segmented_icon_toggle.dart
new file mode 100644
index 0000000..3364e02
--- /dev/null
+++ b/lib/widgets/segmented_icon_toggle.dart
@@ -0,0 +1,65 @@
+import 'package:flutter/material.dart';
+
+/// A single icon button that fills with a solid circle when active —
+/// the building block for Drive-style toggle controls.
+class ToggleIconButton extends StatelessWidget {
+ final IconData icon;
+ final bool isSelected;
+ final VoidCallback onTap;
+ final String? tooltip;
+
+ const ToggleIconButton({
+ super.key,
+ required this.icon,
+ required this.isSelected,
+ required this.onTap,
+ this.tooltip,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ final button = Material(
+ color: isSelected ? colorScheme.primaryContainer : Colors.transparent,
+ shape: const CircleBorder(),
+ child: InkWell(
+ onTap: onTap,
+ customBorder: const CircleBorder(),
+ child: Padding(
+ padding: const EdgeInsets.all(8),
+ child: Icon(
+ icon,
+ size: 20,
+ color: isSelected
+ ? colorScheme.onPrimaryContainer
+ : colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ );
+
+ return tooltip != null ? Tooltip(message: tooltip!, child: button) : button;
+ }
+}
+
+/// A pill-shaped row of [ToggleIconButton]s sharing one background, matching
+/// the list/grid view switch seen in Google Drive's toolbar.
+class SegmentedIconGroup extends StatelessWidget {
+ final List children;
+
+ const SegmentedIconGroup({super.key, required this.children});
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return Container(
+ padding: const EdgeInsets.all(2),
+ decoration: BoxDecoration(
+ color: colorScheme.surfaceContainerHigh,
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Row(mainAxisSize: MainAxisSize.min, children: children),
+ );
+ }
+}
diff --git a/pubspec.lock b/pubspec.lock
index 52c5610..5a3d86c 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1,6 +1,22 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
+ android_file_picker:
+ dependency: transitive
+ description:
+ name: android_file_picker
+ sha256: "14ab27769b54c48d5a8a71aa9858372b7a3ae77572ea0a8aee744643c5d8c53d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.0"
+ archive:
+ dependency: transitive
+ description:
+ name: archive
+ sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.3.0"
args:
dependency: transitive
description:
@@ -33,6 +49,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
+ checked_yaml:
+ dependency: transitive
+ description:
+ name: checked_yaml
+ sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.4"
+ cli_util:
+ dependency: transitive
+ description:
+ name: cli_util
+ sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.4.2"
clock:
dependency: transitive
description:
@@ -57,14 +89,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
+ cross_file:
+ dependency: transitive
+ description:
+ name: cross_file
+ sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.3.5+5"
crypto:
- dependency: "direct main"
+ dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
+ csslib:
+ dependency: transitive
+ description:
+ name: csslib
+ sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@@ -73,6 +121,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.9"
+ dbus:
+ dependency: transitive
+ description:
+ name: dbus
+ sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.15"
+ dio:
+ dependency: "direct main"
+ description:
+ name: dio
+ sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.11.1"
+ dio_web_adapter:
+ dependency: transitive
+ description:
+ name: dio_web_adapter
+ sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.2"
+ dynamic_color:
+ dependency: "direct main"
+ description:
+ name: dynamic_color
+ sha256: "4aeb76de323e8eb660bab5557d826ad7ebbf1434a598f0c6aa8a11a9696a6757"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.9.0"
+ extension:
+ dependency: transitive
+ description:
+ name: extension
+ sha256: "1d6cc5a2d173407284780b68fd94f2b547184d4190ca7dbf6d0bf43eb43c29d8"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.0"
fake_async:
dependency: transitive
description:
@@ -89,11 +177,91 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
+ ffi_leak_tracker:
+ dependency: transitive
+ description:
+ name: ffi_leak_tracker
+ sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.1.2"
+ file:
+ dependency: transitive
+ description:
+ name: file
+ sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.1"
+ file_picker:
+ dependency: "direct main"
+ description:
+ name: file_picker
+ sha256: "98c0b156b6380ba55bc767a14e2e6b3feb246e713ad40092424596fa79005bea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "13.1.0"
+ file_picker_darwin:
+ dependency: transitive
+ description:
+ name: file_picker_darwin
+ sha256: deb3ff69d58656b885c85bc1627f09ecb79860f272a5e7577149582ede4dce5d
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.0"
+ file_picker_linux:
+ dependency: transitive
+ description:
+ name: file_picker_linux
+ sha256: e7db600f50672ce5ebbe422ac0906e0f3d2a476188cc93e7126afee12bf08063
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.0"
+ file_picker_platform_interface:
+ dependency: transitive
+ description:
+ name: file_picker_platform_interface
+ sha256: bbdc085a6f168e63f147e9ce8988f79fa0800a58e7f25a48f762c60837107ba5
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.0.0"
+ file_picker_web:
+ dependency: transitive
+ description:
+ name: file_picker_web
+ sha256: b3004268da0c1b52baa18df430e7ec4438194de855a807d26b447cbf07ef3496
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.0.0"
+ file_saver:
+ dependency: "direct main"
+ description:
+ name: file_saver
+ sha256: "017a127de686af2d2fbbd64afea97052d95f2a0f87d19d25b87e097407bf9c1e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.2.14"
+ fixnum:
+ dependency: transitive
+ description:
+ name: fixnum
+ sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
+ flutter_launcher_icons:
+ dependency: "direct dev"
+ description:
+ name: flutter_launcher_icons
+ sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
@@ -102,11 +270,64 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
+ flutter_secure_storage:
+ dependency: "direct main"
+ description:
+ name: flutter_secure_storage
+ sha256: d87713a152ee2f255117bdbbf43da1dea1797e0551e499e0334f0c9dcfafddd2
+ url: "https://pub.dev"
+ source: hosted
+ version: "11.1.1"
+ flutter_secure_storage_darwin:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_darwin
+ sha256: d0b136b1e21fd4081170fc394e64197046ce889e8f63f2370a13e41b832cc044
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.4.2"
+ flutter_secure_storage_linux:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_linux
+ sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.3"
+ flutter_secure_storage_platform_interface:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_platform_interface
+ sha256: "4bc033841169d07f690d46d89dbc3f5305b6562820822445384c82e0866e2719"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.0"
+ flutter_secure_storage_web:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_web
+ sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.1"
+ flutter_secure_storage_windows:
+ dependency: transitive
+ description:
+ name: flutter_secure_storage_windows
+ sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.2.2"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
+ flutter_web_plugins:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
google_fonts:
dependency: "direct main"
description:
@@ -123,6 +344,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.2"
+ html:
+ dependency: transitive
+ description:
+ name: html
+ sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.15.7"
http:
dependency: "direct main"
description:
@@ -139,6 +368,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
+ image:
+ dependency: transitive
+ description:
+ name: image
+ sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.10.1"
intl:
dependency: "direct main"
description:
@@ -171,6 +408,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
+ json_annotation:
+ dependency: transitive
+ description:
+ name: json_annotation
+ sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.12.0"
leak_tracker:
dependency: transitive
description:
@@ -215,10 +460,10 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
+ sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
- version: "0.12.19"
+ version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
@@ -227,14 +472,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.13.0"
+ material_symbols_icons:
+ dependency: "direct main"
+ description:
+ name: material_symbols_icons
+ sha256: bd513edc2bc9b034108d5518c48cf5cebbb67d0653728cc452368cb27ca80bb0
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.2960.0"
meta:
dependency: transitive
description:
name: meta
- sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
+ sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
- version: "1.18.0"
+ version: "1.19.0"
+ mime:
+ dependency: transitive
+ description:
+ name: mime
+ sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.0"
nested:
dependency: transitive
description:
@@ -251,6 +512,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "9.5.0"
+ open_file:
+ dependency: "direct main"
+ description:
+ name: open_file
+ sha256: b22decdae85b459eac24aeece48f33845c6f16d278a9c63d75c5355345ca236b
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.5.11"
+ open_file_android:
+ dependency: transitive
+ description:
+ name: open_file_android
+ sha256: f48a342b0347bf82bebf68c752f5cfce162965ce8449c5ecdafc406d294aa63d
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.0"
+ open_file_ios:
+ dependency: transitive
+ description:
+ name: open_file_ios
+ sha256: "0e3e67dcd12f69888128f533cacc092039627dd4f1d7a95a2826b814ae4ea8aa"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.0"
+ open_file_linux:
+ dependency: transitive
+ description:
+ name: open_file_linux
+ sha256: d189f799eecbb139c97f8bc7d303f9e720954fa4e0fa1b0b7294767e5f2d7550
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.0.5"
+ open_file_mac:
+ dependency: transitive
+ description:
+ name: open_file_mac
+ sha256: "1abe00e65f792b9baa1e15fdf4d44af5e813b1696ac307d71535543122a01e30"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.0"
+ open_file_platform_interface:
+ dependency: transitive
+ description:
+ name: open_file_platform_interface
+ sha256: dc302e4bd5c1d77acec4240bd0dbc1d9f2ee6882b1ce52bca5298369da7acf12
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.0"
+ open_file_web:
+ dependency: transitive
+ description:
+ name: open_file_web
+ sha256: e3dbc9584856283dcb30aef5720558b90f88036360bd078e494ab80a80130c4f
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.0.4"
+ open_file_windows:
+ dependency: transitive
+ description:
+ name: open_file_windows
+ sha256: d26c31ddf935a94a1a3aa43a23f4fff8a5ff4eea395fe7a8cb819cf55431c875
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.0.3"
package_config:
dependency: transitive
description:
@@ -268,7 +593,7 @@ packages:
source: hosted
version: "1.9.1"
path_provider:
- dependency: transitive
+ dependency: "direct main"
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
@@ -315,6 +640,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
+ pdfx:
+ dependency: "direct main"
+ description:
+ name: pdfx
+ sha256: "83bcd2b971fc2f6ee4299659c96d7d66b2ba4807748571a8a4889f1cde14b754"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.11.0"
petitparser:
dependency: transitive
description:
@@ -323,6 +656,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.2"
+ photo_view:
+ dependency: transitive
+ description:
+ name: photo_view
+ sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.15.0"
platform:
dependency: transitive
description:
@@ -339,6 +680,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
+ posix:
+ dependency: transitive
+ description:
+ name: posix
+ sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.5.2"
provider:
dependency: "direct main"
description:
@@ -363,6 +712,78 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.0"
+ share_plus:
+ dependency: "direct main"
+ description:
+ name: share_plus
+ sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "13.3.0"
+ share_plus_platform_interface:
+ dependency: transitive
+ description:
+ name: share_plus_platform_interface
+ sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41"
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.2.0"
+ shared_preferences:
+ dependency: "direct main"
+ description:
+ name: shared_preferences
+ sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.5"
+ shared_preferences_android:
+ dependency: transitive
+ description:
+ name: shared_preferences_android
+ sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.28"
+ shared_preferences_foundation:
+ dependency: transitive
+ description:
+ name: shared_preferences_foundation
+ sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.5.7"
+ shared_preferences_linux:
+ dependency: transitive
+ description:
+ name: shared_preferences_linux
+ sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.1"
+ shared_preferences_platform_interface:
+ dependency: transitive
+ description:
+ name: shared_preferences_platform_interface
+ sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.2"
+ shared_preferences_web:
+ dependency: transitive
+ description:
+ name: shared_preferences_web
+ sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.3"
+ shared_preferences_windows:
+ dependency: transitive
+ description:
+ name: shared_preferences_windows
+ sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
@@ -400,6 +821,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
+ synchronized:
+ dependency: transitive
+ description:
+ name: synchronized
+ sha256: "397b146f97613b83d84bdccb439bc8d0f3aebb6c1d14a9641e6f24201c490b2d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.4.2"
term_glyph:
dependency: transitive
description:
@@ -412,10 +841,10 @@ packages:
dependency: transitive
description:
name: test_api
- sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
+ sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
- version: "0.7.11"
+ version: "0.7.12"
typed_data:
dependency: transitive
description:
@@ -424,14 +853,134 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
+ universal_platform:
+ dependency: transitive
+ description:
+ name: universal_platform
+ sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.0"
+ url_launcher:
+ dependency: "direct main"
+ description:
+ name: url_launcher
+ sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.3.2"
+ url_launcher_android:
+ dependency: transitive
+ description:
+ name: url_launcher_android
+ sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.3.33"
+ url_launcher_ios:
+ dependency: transitive
+ description:
+ name: url_launcher_ios
+ sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.4.2"
+ url_launcher_linux:
+ dependency: transitive
+ description:
+ name: url_launcher_linux
+ sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.3"
+ url_launcher_macos:
+ dependency: transitive
+ description:
+ name: url_launcher_macos
+ sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.6"
+ url_launcher_platform_interface:
+ dependency: transitive
+ description:
+ name: url_launcher_platform_interface
+ sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.3.2"
+ url_launcher_web:
+ dependency: transitive
+ description:
+ name: url_launcher_web
+ sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.3"
+ url_launcher_windows:
+ dependency: transitive
+ description:
+ name: url_launcher_windows
+ sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.6"
+ uuid:
+ dependency: transitive
+ description:
+ name: uuid
+ sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.6.0"
vector_math:
dependency: transitive
description:
name: vector_math
- sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
+ sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev"
source: hosted
- version: "2.2.0"
+ version: "2.4.2"
+ video_player:
+ dependency: "direct main"
+ description:
+ name: video_player
+ sha256: "8c837b570dccb9ae6ff73d2e0b03c7e708bfefd3bd1194faa7f3e7f200dfc399"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.14.0"
+ video_player_android:
+ dependency: transitive
+ description:
+ name: video_player_android
+ sha256: d27054dea34d748a44f06d433a57005d2aac69944485b0abbaed3303dbcfa3f1
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.12.2"
+ video_player_avfoundation:
+ dependency: transitive
+ description:
+ name: video_player_avfoundation
+ sha256: "2fc56e537324a19eb649fca991aa308ac961b5406292876fa657b062de461fd8"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.12.0"
+ video_player_platform_interface:
+ dependency: transitive
+ description:
+ name: video_player_platform_interface
+ sha256: "92c0fbabe20c788e71fd10d26cea998d0d253282e65d145aed0818731cf593ce"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.9.0"
+ video_player_web:
+ dependency: transitive
+ description:
+ name: video_player_web
+ sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.0"
vm_service:
dependency: transitive
description:
@@ -448,6 +997,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
+ win32:
+ dependency: transitive
+ description:
+ name: win32
+ sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.4.0"
+ windows_file_picker:
+ dependency: transitive
+ description:
+ name: windows_file_picker
+ sha256: "9f3aa833068b09e380fdc59560ad260a4a14e9397173abb533699f967443602e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.0"
xdg_directories:
dependency: transitive
description:
@@ -474,4 +1039,4 @@ packages:
version: "3.1.4"
sdks:
dart: ">=3.12.2 <4.0.0"
- flutter: ">=3.38.4"
+ flutter: ">=3.44.0"
diff --git a/pubspec.yaml b/pubspec.yaml
index a409597..9481993 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -36,11 +36,23 @@ dependencies:
cupertino_icons: ^1.0.8
provider: ^6.1.2
google_fonts: ^6.2.1
+ dynamic_color: ^1.7.0
http: ^1.2.1
xml: ^6.5.0
intl: ^0.19.0
path: ^1.9.0
- crypto: ^3.0.3
+ flutter_secure_storage: ^11.1.1
+ url_launcher: ^6.3.1
+ dio: ^5.11.1
+ path_provider: ^2.1.5
+ open_file: ^3.5.10
+ file_saver: ^0.2.14
+ video_player: ^2.9.5
+ pdfx: ^2.9.2
+ file_picker: ^13.1.0
+ share_plus: ^13.3.0
+ material_symbols_icons: ^4.2960.0
+ shared_preferences: ^2.5.5
dev_dependencies:
flutter_test:
@@ -52,6 +64,13 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
+ flutter_launcher_icons: ^0.14.4
+
+flutter_launcher_icons:
+ android: true
+ image_path: "assets/icon/app_icon.png"
+ adaptive_icon_background: "#3D5AD1"
+ adaptive_icon_foreground: "assets/icon/app_icon_foreground.png"
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
diff --git a/web/favicon.png b/web/favicon.png
deleted file mode 100644
index 8aaa46a..0000000
Binary files a/web/favicon.png and /dev/null differ
diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png
deleted file mode 100644
index b749bfe..0000000
Binary files a/web/icons/Icon-192.png and /dev/null differ
diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png
deleted file mode 100644
index 88cfd48..0000000
Binary files a/web/icons/Icon-512.png and /dev/null differ
diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png
deleted file mode 100644
index eb9b4d7..0000000
Binary files a/web/icons/Icon-maskable-192.png and /dev/null differ
diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png
deleted file mode 100644
index d69c566..0000000
Binary files a/web/icons/Icon-maskable-512.png and /dev/null differ
diff --git a/web/index.html b/web/index.html
deleted file mode 100644
index 2ecc51a..0000000
--- a/web/index.html
+++ /dev/null
@@ -1,46 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- nextcloud_client
-
-
-
-
-
-
-
diff --git a/web/manifest.json b/web/manifest.json
deleted file mode 100644
index 68f283e..0000000
--- a/web/manifest.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
- "name": "nextcloud_client",
- "short_name": "nextcloud_client",
- "start_url": ".",
- "display": "standalone",
- "background_color": "#0175C2",
- "theme_color": "#0175C2",
- "description": "A new Flutter project.",
- "orientation": "portrait-primary",
- "prefer_related_applications": false,
- "icons": [
- {
- "src": "icons/Icon-192.png",
- "sizes": "192x192",
- "type": "image/png"
- },
- {
- "src": "icons/Icon-512.png",
- "sizes": "512x512",
- "type": "image/png"
- },
- {
- "src": "icons/Icon-maskable-192.png",
- "sizes": "192x192",
- "type": "image/png",
- "purpose": "maskable"
- },
- {
- "src": "icons/Icon-maskable-512.png",
- "sizes": "512x512",
- "type": "image/png",
- "purpose": "maskable"
- }
- ]
-}
diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc
index 8b6d468..5df7a5a 100644
--- a/windows/flutter/generated_plugin_registrant.cc
+++ b/windows/flutter/generated_plugin_registrant.cc
@@ -6,6 +6,24 @@
#include "generated_plugin_registrant.h"
+#include
+#include
+#include
+#include
+#include
+#include
void RegisterPlugins(flutter::PluginRegistry* registry) {
+ DynamicColorPluginCApiRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
+ FileSaverPluginRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("FileSaverPlugin"));
+ FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
+ PdfxPluginRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("PdfxPlugin"));
+ SharePlusWindowsPluginCApiRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
+ UrlLauncherWindowsRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}
diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake
index 3ad69c6..ee06182 100644
--- a/windows/flutter/generated_plugins.cmake
+++ b/windows/flutter/generated_plugins.cmake
@@ -3,6 +3,12 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
+ dynamic_color
+ file_saver
+ flutter_secure_storage_windows
+ pdfx
+ share_plus
+ url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST