refactored HTTP error handling and streamlined UI components by centralizing shared logic and removing redundant parameters
This commit is contained in:
@@ -1 +0,0 @@
|
||||
class ApiRequest {}
|
||||
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
|
||||
import '../api_error.dart';
|
||||
import '../http_errors.dart';
|
||||
import '../marianumcloud/talk/talk_error.dart';
|
||||
import 'app_exception.dart';
|
||||
import 'auth_exception.dart';
|
||||
@@ -59,9 +60,8 @@ AppException? _dioToAppException(DioException error) {
|
||||
/// status plus a trimmed body preview (same format as the Talk API errors).
|
||||
AppException _dynamiteToAppException(DynamiteApiException error) {
|
||||
final status = error.statusCode;
|
||||
final body = error.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body;
|
||||
final detail = body.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
|
||||
final preview = previewBody(error.body);
|
||||
final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
|
||||
switch (status) {
|
||||
case 401:
|
||||
return AuthException.unauthorized(technicalDetails: detail);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'errors/auth_exception.dart';
|
||||
import 'errors/network_exception.dart';
|
||||
import 'errors/not_found_exception.dart';
|
||||
import 'errors/server_exception.dart';
|
||||
|
||||
/// Runs [send] and converts transport-level failures (socket/timeout/client
|
||||
/// errors) into a [NetworkException] tagged with [label] (e.g. `Talk <uri>`).
|
||||
/// Passes through whatever [send] produces, including `null` for the base-class
|
||||
/// request hooks that may skip the call.
|
||||
Future<http.Response?> sendGuarded(
|
||||
String label,
|
||||
Future<http.Response>? Function() send,
|
||||
) async {
|
||||
try {
|
||||
return await send();
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: '$label: ${e.message}');
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: '$label: $e');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: '$label: ${e.message}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapses whitespace and caps an HTTP error body at 500 chars so it can be
|
||||
/// embedded in an [AppException]'s technical details without dumping headers.
|
||||
String previewBody(String body) {
|
||||
final collapsed = body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
return collapsed.length > 500 ? '${collapsed.substring(0, 500)}…' : collapsed;
|
||||
}
|
||||
|
||||
/// Builds a `<label> -> HTTP <status>[ body=<preview>]` technical detail line.
|
||||
String httpErrorDetail(String label, String body, int status) {
|
||||
final preview = previewBody(body);
|
||||
return preview.isEmpty
|
||||
? '$label -> HTTP $status'
|
||||
: '$label -> HTTP $status body=$preview';
|
||||
}
|
||||
|
||||
/// Throws the [AppException] matching a non-2xx HTTP [status], carrying
|
||||
/// [detail] as technical details: 401/403 map to auth errors, 404 to
|
||||
/// not-found, everything else to a generic server error.
|
||||
Never throwForStatus(int status, String detail) {
|
||||
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
|
||||
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
|
||||
if (status == 404) throw NotFoundException(technicalDetails: detail);
|
||||
throw ServerException(statusCode: status, technicalDetails: detail);
|
||||
}
|
||||
@@ -1,18 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../model/account_data.dart';
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../errors/auth_exception.dart';
|
||||
import '../../errors/network_exception.dart';
|
||||
import '../../errors/not_found_exception.dart';
|
||||
import '../../errors/parse_exception.dart';
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../../http_errors.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
|
||||
/// Mix of two Nextcloud surfaces:
|
||||
@@ -42,30 +37,17 @@ Future<http.Response> _send(
|
||||
) async {
|
||||
final headers = NextcloudOcs.headers();
|
||||
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await perform(uri, headers);
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: 'Cloud $uri: $e');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
|
||||
}
|
||||
final response = (await sendGuarded(
|
||||
'Cloud $uri',
|
||||
() => perform(uri, headers),
|
||||
))!;
|
||||
|
||||
final status = response.statusCode;
|
||||
if (status >= 200 && status < 300) return response;
|
||||
|
||||
final body = response.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body;
|
||||
final detail = body.isEmpty
|
||||
? 'Cloud $uri -> HTTP $status'
|
||||
: 'Cloud $uri -> HTTP $status body=$preview';
|
||||
final detail = httpErrorDetail('Cloud $uri', response.body, status);
|
||||
log(detail);
|
||||
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
|
||||
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
|
||||
if (status == 404) throw NotFoundException(technicalDetails: detail);
|
||||
throw ServerException(statusCode: status, technicalDetails: detail);
|
||||
throwForStatus(status, detail);
|
||||
}
|
||||
|
||||
class SetUserAvatar {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../errors/network_exception.dart';
|
||||
import '../../../errors/server_exception.dart';
|
||||
import '../../../http_errors.dart';
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import 'get_chat_params.dart';
|
||||
import 'get_chat_response.dart';
|
||||
@@ -40,18 +37,12 @@ class LongPollChat {
|
||||
);
|
||||
final headers = NextcloudOcs.headers();
|
||||
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await http
|
||||
final response = (await sendGuarded(
|
||||
'LongPollChat $uri',
|
||||
() => http
|
||||
.get(uri, headers: headers)
|
||||
.timeout(Duration(seconds: timeoutSeconds + 15));
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: 'LongPollChat $uri: $e');
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
|
||||
}
|
||||
.timeout(Duration(seconds: timeoutSeconds + 15)),
|
||||
))!;
|
||||
|
||||
final status = response.statusCode;
|
||||
if (status == 304) return null;
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../api_params.dart';
|
||||
import '../../api_request.dart';
|
||||
import '../../api_response.dart';
|
||||
import '../../errors/auth_exception.dart';
|
||||
import '../../errors/network_exception.dart';
|
||||
import '../../errors/not_found_exception.dart';
|
||||
import '../../errors/parse_exception.dart';
|
||||
import '../../errors/server_exception.dart';
|
||||
import '../../http_errors.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
|
||||
enum TalkApiMethod { get, post, put, delete }
|
||||
|
||||
abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
|
||||
abstract class TalkApi<T extends ApiResponse?> {
|
||||
String path;
|
||||
ApiParams? body;
|
||||
Map<String, String>? headers;
|
||||
Map<String, dynamic>? getParameters;
|
||||
|
||||
http.Response? response;
|
||||
|
||||
TalkApi(this.path, this.body, {this.headers, this.getParameters});
|
||||
|
||||
Future<http.Response>? request(
|
||||
@@ -40,22 +31,15 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
|
||||
);
|
||||
final mergedHeaders = {...NextcloudOcs.headers(), ...?headers};
|
||||
|
||||
final http.Response data;
|
||||
try {
|
||||
final raw = await request(endpoint, body, mergedHeaders);
|
||||
if (raw == null) {
|
||||
throw const NetworkException(
|
||||
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
|
||||
technicalDetails: 'Talk request returned null',
|
||||
);
|
||||
}
|
||||
data = raw;
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: 'Talk $endpoint: $e');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
|
||||
final data = await sendGuarded(
|
||||
'Talk $endpoint',
|
||||
() => request(endpoint, body, mergedHeaders),
|
||||
);
|
||||
if (data == null) {
|
||||
throw const NetworkException(
|
||||
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
|
||||
technicalDetails: 'Talk request returned null',
|
||||
);
|
||||
}
|
||||
|
||||
final status = data.statusCode;
|
||||
@@ -63,20 +47,9 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
|
||||
// Talk's OCS errors carry the real reason in the body (expired session,
|
||||
// removed participant, ...); include a trimmed preview so the dialog and
|
||||
// logs surface the cause instead of just the bare status code.
|
||||
final body = data.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
final preview = body.length > 500 ? '${body.substring(0, 500)}…' : body;
|
||||
final detail = body.isEmpty
|
||||
? 'Talk $endpoint -> HTTP $status'
|
||||
: 'Talk $endpoint -> HTTP $status body=$preview';
|
||||
final detail = httpErrorDetail('Talk $endpoint', data.body, status);
|
||||
log(detail);
|
||||
if (status == 401) {
|
||||
throw AuthException.unauthorized(technicalDetails: detail);
|
||||
}
|
||||
if (status == 403) {
|
||||
throw AuthException.forbidden(technicalDetails: detail);
|
||||
}
|
||||
if (status == 404) throw NotFoundException(technicalDetails: detail);
|
||||
throw ServerException(statusCode: status, technicalDetails: detail);
|
||||
throwForStatus(status, detail);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,10 +2,9 @@ import 'package:nextcloud/nextcloud.dart';
|
||||
|
||||
import '../../../model/account_data.dart';
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../api_request.dart';
|
||||
import '../../api_response.dart';
|
||||
|
||||
abstract class WebdavApi<T> extends ApiRequest {
|
||||
abstract class WebdavApi<T> {
|
||||
T genericParams;
|
||||
|
||||
WebdavApi(this.genericParams) {
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
|
||||
import '../api_request.dart';
|
||||
import '../errors/network_exception.dart';
|
||||
import '../errors/parse_exception.dart';
|
||||
import '../errors/server_exception.dart';
|
||||
import '../http_errors.dart';
|
||||
|
||||
abstract class MhslApi<T> extends ApiRequest {
|
||||
abstract class MhslApi<T> {
|
||||
String subpath;
|
||||
MhslApi(this.subpath);
|
||||
|
||||
http.Response? response;
|
||||
|
||||
Future<http.Response>? request(Uri uri);
|
||||
T assemble(String raw);
|
||||
|
||||
@@ -24,22 +19,12 @@ abstract class MhslApi<T> extends ApiRequest {
|
||||
'https://mhsl.eu/marianum/marianummobile/$subpath',
|
||||
);
|
||||
|
||||
final http.Response data;
|
||||
try {
|
||||
final raw = await request(endpoint);
|
||||
if (raw == null) {
|
||||
throw const NetworkException(
|
||||
userMessage: 'Keine Antwort vom MHSL-Dienst erhalten.',
|
||||
technicalDetails: 'mhsl request returned null',
|
||||
);
|
||||
}
|
||||
data = raw;
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}');
|
||||
} on TimeoutException catch (e) {
|
||||
throw NetworkException.timeout(technicalDetails: 'mhsl $subpath: $e');
|
||||
} on http.ClientException catch (e) {
|
||||
throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}');
|
||||
final data = await sendGuarded('mhsl $subpath', () => request(endpoint));
|
||||
if (data == null) {
|
||||
throw const NetworkException(
|
||||
userMessage: 'Keine Antwort vom MHSL-Dienst erhalten.',
|
||||
technicalDetails: 'mhsl request returned null',
|
||||
);
|
||||
}
|
||||
|
||||
if (data.statusCode > 299) {
|
||||
@@ -55,8 +40,4 @@ abstract class MhslApi<T> extends ApiRequest {
|
||||
throw ParseException(technicalDetails: 'mhsl $subpath assemble: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static String dateTimeToJson(DateTime time) =>
|
||||
Jiffy.parseFromDateTime(time).format(pattern: 'yyyy-MM-dd HH:mm:ss');
|
||||
static DateTime dateTimeFromJson(String time) => DateTime.parse(time);
|
||||
}
|
||||
|
||||
+1
-3
@@ -256,9 +256,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
|
||||
if (totalTabs != _knownTotalTabs) {
|
||||
var targetIndex = currentIndex;
|
||||
if (_userOnLastTab) {
|
||||
targetIndex = totalTabs - 1;
|
||||
} else if (currentIndex >= totalTabs) {
|
||||
if (_userOnLastTab || currentIndex >= totalTabs) {
|
||||
targetIndex = totalTabs - 1;
|
||||
}
|
||||
// Replace the controller atomically: a stale index past the new
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
abstract class DataLoader<TResult> {
|
||||
final Dio dio;
|
||||
DataLoader(this.dio) {
|
||||
dio.options.connectTimeout = const Duration(seconds: 10);
|
||||
dio.options.sendTimeout = const Duration(seconds: 30);
|
||||
dio.options.receiveTimeout = const Duration(seconds: 30);
|
||||
}
|
||||
|
||||
Future<TResult> run() async {
|
||||
final response = await fetch();
|
||||
try {
|
||||
return assemble(
|
||||
DataLoaderResult(
|
||||
json: jsonDecode(response.data!),
|
||||
headers: response.headers.map.map(
|
||||
(key, value) => MapEntry(key, value.join(';')),
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e, stack) {
|
||||
log('DataLoader assemble failed', error: e, stackTrace: stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response<String>> fetch();
|
||||
TResult assemble(DataLoaderResult data);
|
||||
}
|
||||
|
||||
class DataLoaderResult {
|
||||
final dynamic json;
|
||||
final Map<String, String> headers;
|
||||
|
||||
Map<String, dynamic> asMap() => json as Map<String, dynamic>;
|
||||
List<dynamic> asList() => json as List<dynamic>;
|
||||
List<Map<String, dynamic>> asListOfMaps() =>
|
||||
asList().map((e) => e as Map<String, dynamic>).toList();
|
||||
|
||||
DataLoaderResult({required this.json, required this.headers});
|
||||
}
|
||||
@@ -19,12 +19,5 @@ abstract class LoadableState<TState> with _$LoadableState<TState> {
|
||||
String? statusText,
|
||||
}) = _LoadableState<TState>;
|
||||
|
||||
bool _hasError() => error != null;
|
||||
bool _hasData() => data != null;
|
||||
|
||||
bool showPrimaryLoading() => isLoading && !_hasData();
|
||||
bool showBackgroundLoading() => isLoading && _hasData();
|
||||
bool showErrorBar() => _hasError() && _hasData();
|
||||
bool showError() => _hasError() && !_hasData();
|
||||
bool showContent() => _hasData();
|
||||
bool showContent() => data != null;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ class DownloadManager {
|
||||
|
||||
final Map<String, DownloadJob> _jobs = {}; // keyed by remotePath
|
||||
final Map<String, DownloadJob> _byTaskId = {};
|
||||
final Map<String, bd.DownloadTask> _taskById = {};
|
||||
|
||||
/// All jobs the user should currently see in the downloads tray/overview:
|
||||
/// everything that is in progress or finished-but-not-yet-opened (failed
|
||||
@@ -134,7 +133,6 @@ class DownloadManager {
|
||||
)..taskId = task.taskId;
|
||||
_jobs[remotePath] = job;
|
||||
_byTaskId[task.taskId] = job;
|
||||
_taskById[task.taskId] = task;
|
||||
_refreshVisible();
|
||||
|
||||
final ok = await bd.FileDownloader().enqueue(task);
|
||||
@@ -217,10 +215,7 @@ class DownloadManager {
|
||||
job.status.value = const DownloadCancelled();
|
||||
}
|
||||
_jobs.remove(job.remotePath);
|
||||
if (taskId != null) {
|
||||
_byTaskId.remove(taskId);
|
||||
_taskById.remove(taskId);
|
||||
}
|
||||
if (taskId != null) _byTaskId.remove(taskId);
|
||||
scheduleMicrotask(job.dispose);
|
||||
}
|
||||
_refreshVisible();
|
||||
@@ -314,10 +309,7 @@ class DownloadManager {
|
||||
void _remove(DownloadJob job) {
|
||||
_jobs.remove(job.remotePath);
|
||||
final taskId = job.taskId;
|
||||
if (taskId != null) {
|
||||
_byTaskId.remove(taskId);
|
||||
_taskById.remove(taskId);
|
||||
}
|
||||
if (taskId != null) _byTaskId.remove(taskId);
|
||||
_refreshVisible();
|
||||
scheduleMicrotask(job.dispose);
|
||||
}
|
||||
|
||||
@@ -171,21 +171,18 @@ class _FileElementState extends State<FileElement>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _delete() async {
|
||||
void _delete() {
|
||||
if (guardDemoAction(context)) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
title: 'Element löschen?',
|
||||
content: 'Das Element wird unwiederruflich gelöscht.',
|
||||
confirmButton: 'Löschen',
|
||||
onConfirmAsync: () async {
|
||||
final webdav = await WebdavApi.webdav;
|
||||
await webdav.delete(PathUri.parse(widget.file.path));
|
||||
widget.refetch();
|
||||
},
|
||||
),
|
||||
);
|
||||
ConfirmDialog(
|
||||
title: 'Element löschen?',
|
||||
content: 'Das Element wird unwiederruflich gelöscht.',
|
||||
confirmButton: 'Löschen',
|
||||
onConfirmAsync: () async {
|
||||
final webdav = await WebdavApi.webdav;
|
||||
await webdav.delete(PathUri.parse(widget.file.path));
|
||||
widget.refetch();
|
||||
},
|
||||
).asDialog(context);
|
||||
}
|
||||
|
||||
void _showActionSheet() {
|
||||
|
||||
@@ -86,20 +86,11 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
|
||||
trailing: DropdownButton<ChatBackgroundType>(
|
||||
value: s.type,
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
items: ChatBackgroundType.values
|
||||
.map(
|
||||
(e) => DropdownMenuItem<ChatBackgroundType>(
|
||||
value: e,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_typeIcon(e)),
|
||||
const SizedBox(width: 10),
|
||||
Text(_typeLabel(e)),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
items: _iconDropdownItems(
|
||||
ChatBackgroundType.values,
|
||||
_typeIcon,
|
||||
_typeLabel,
|
||||
),
|
||||
onChanged: (e) => _onTypeChanged(context, settings, e!),
|
||||
),
|
||||
),
|
||||
@@ -142,20 +133,11 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
|
||||
trailing: DropdownButton<ChatBackgroundFit>(
|
||||
value: s.fit,
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
items: ChatBackgroundFit.values
|
||||
.map(
|
||||
(e) => DropdownMenuItem<ChatBackgroundFit>(
|
||||
value: e,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_fitIcon(e)),
|
||||
const SizedBox(width: 10),
|
||||
Text(_fitLabel(e)),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
items: _iconDropdownItems(
|
||||
ChatBackgroundFit.values,
|
||||
_fitIcon,
|
||||
_fitLabel,
|
||||
),
|
||||
onChanged: (e) {
|
||||
Haptics.selection();
|
||||
settings.val(write: true).chatBackgroundSettings.fit =
|
||||
@@ -280,6 +262,27 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
|
||||
cs.type = ChatBackgroundType.color;
|
||||
}
|
||||
|
||||
/// Dropdown menu items rendered as an icon + label row, shared by the source
|
||||
/// and fill-mode pickers.
|
||||
static List<DropdownMenuItem<T>> _iconDropdownItems<T>(
|
||||
List<T> values,
|
||||
IconData Function(T) icon,
|
||||
String Function(T) label,
|
||||
) => values
|
||||
.map(
|
||||
(e) => DropdownMenuItem<T>(
|
||||
value: e,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon(e)),
|
||||
const SizedBox(width: 10),
|
||||
Text(label(e)),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
IconData _typeIcon(ChatBackgroundType type) => switch (type) {
|
||||
ChatBackgroundType.pattern => Icons.texture_outlined,
|
||||
ChatBackgroundType.image => Icons.image_outlined,
|
||||
@@ -330,7 +333,6 @@ class _Preview extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_bubble(
|
||||
context,
|
||||
alignment: Alignment.centerLeft,
|
||||
color: remoteColor,
|
||||
text: 'Wie gefällt dir der neue Hintergrund?',
|
||||
@@ -338,7 +340,6 @@ class _Preview extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_bubble(
|
||||
context,
|
||||
alignment: Alignment.centerRight,
|
||||
color: selfColor,
|
||||
text: 'Sieht richtig gut aus! 🎉',
|
||||
@@ -352,8 +353,7 @@ class _Preview extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bubble(
|
||||
BuildContext context, {
|
||||
Widget _bubble({
|
||||
required Alignment alignment,
|
||||
required Color color,
|
||||
required String text,
|
||||
|
||||
@@ -245,7 +245,6 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
lastDate = elementDate;
|
||||
messages.add(
|
||||
ChatBubble(
|
||||
context: context,
|
||||
isSender: false,
|
||||
bubbleData: GetChatResponseObject.getDateDummy(element.timestamp),
|
||||
chatData: widget.room,
|
||||
@@ -265,7 +264,6 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
|
||||
messages.add(
|
||||
ChatBubble(
|
||||
context: context,
|
||||
isSender:
|
||||
element.actorId == widget.selfId &&
|
||||
(element.messageType ==
|
||||
@@ -287,7 +285,6 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
messages.insert(
|
||||
0,
|
||||
ChatBubble(
|
||||
context: context,
|
||||
isSender: false,
|
||||
bubbleData: GetChatResponseObject.getTextDummy(
|
||||
'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. '
|
||||
|
||||
@@ -24,29 +24,23 @@ class ChatBubbleStyles {
|
||||
alignment: Alignment.center,
|
||||
);
|
||||
|
||||
BubbleStyle getRemoteStyle(bool seamless) {
|
||||
var color = AppTheme.isDarkMode(context)
|
||||
BubbleStyle getRemoteStyle() => BubbleStyle(
|
||||
nip: BubbleNip.leftTop,
|
||||
color: AppTheme.isDarkMode(context)
|
||||
? const Color(0xff202c33)
|
||||
: Colors.white;
|
||||
return BubbleStyle(
|
||||
nip: BubbleNip.leftTop,
|
||||
color: seamless ? Colors.transparent : color,
|
||||
elevation: seamless ? 0 : 1,
|
||||
margin: const BubbleEdges.only(bottom: 10, left: 10, right: 50),
|
||||
alignment: Alignment.topLeft,
|
||||
);
|
||||
}
|
||||
: Colors.white,
|
||||
elevation: 1,
|
||||
margin: const BubbleEdges.only(bottom: 10, left: 10, right: 50),
|
||||
alignment: Alignment.topLeft,
|
||||
);
|
||||
|
||||
BubbleStyle getSelfStyle(bool seamless) {
|
||||
var color = AppTheme.isDarkMode(context)
|
||||
BubbleStyle getSelfStyle() => BubbleStyle(
|
||||
nip: BubbleNip.rightBottom,
|
||||
color: AppTheme.isDarkMode(context)
|
||||
? const Color(0xff005c4b)
|
||||
: const Color(0xffd3d3d3);
|
||||
return BubbleStyle(
|
||||
nip: BubbleNip.rightBottom,
|
||||
color: seamless ? Colors.transparent : color,
|
||||
elevation: seamless ? 0 : 1,
|
||||
margin: const BubbleEdges.only(bottom: 10, right: 10, left: 50),
|
||||
alignment: Alignment.topRight,
|
||||
);
|
||||
}
|
||||
: const Color(0xffd3d3d3),
|
||||
elevation: 1,
|
||||
margin: const BubbleEdges.only(bottom: 10, right: 10, left: 50),
|
||||
alignment: Alignment.topRight,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,9 @@ import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dar
|
||||
import '../data/chat_bubble_styles.dart';
|
||||
|
||||
class AnswerReference extends StatelessWidget {
|
||||
final BuildContext context;
|
||||
final GetChatResponseObject referenceMessage;
|
||||
final String? selfId;
|
||||
const AnswerReference({
|
||||
required this.context,
|
||||
required this.referenceMessage,
|
||||
required this.selfId,
|
||||
super.key,
|
||||
@@ -20,8 +18,8 @@ class AnswerReference extends StatelessWidget {
|
||||
final style = ChatBubbleStyles(context);
|
||||
final isSelf = referenceMessage.actorId == selfId;
|
||||
final accent = isSelf
|
||||
? style.getSelfStyle(false).color!.withGreen(200)
|
||||
: style.getRemoteStyle(false).color!.withWhite(200);
|
||||
? style.getSelfStyle().color!.withGreen(200)
|
||||
: style.getRemoteStyle().color!.withWhite(200);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.2),
|
||||
|
||||
@@ -23,7 +23,6 @@ import 'highlighted_linkify.dart';
|
||||
enum SearchHighlight { none, secondary, active }
|
||||
|
||||
class ChatBubble extends StatefulWidget {
|
||||
final BuildContext context;
|
||||
final bool isSender;
|
||||
final GetChatResponseObject bubbleData;
|
||||
final GetRoomResponseObject chatData;
|
||||
@@ -40,7 +39,6 @@ class ChatBubble extends StatefulWidget {
|
||||
final SearchHighlight matchHighlight;
|
||||
|
||||
const ChatBubble({
|
||||
required this.context,
|
||||
required this.isSender,
|
||||
required this.bubbleData,
|
||||
required this.chatData,
|
||||
@@ -123,8 +121,8 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
base = styles.getSystemStyle();
|
||||
} else {
|
||||
base = widget.isSender
|
||||
? styles.getSelfStyle(false)
|
||||
: styles.getRemoteStyle(false);
|
||||
? styles.getSelfStyle()
|
||||
: styles.getRemoteStyle();
|
||||
}
|
||||
switch (widget.matchHighlight) {
|
||||
case SearchHighlight.none:
|
||||
@@ -366,7 +364,6 @@ class _BubbleContent extends StatelessWidget {
|
||||
bubbleData.messageType ==
|
||||
GetRoomResponseObjectMessageType.comment) ...[
|
||||
AnswerReference(
|
||||
context: context,
|
||||
referenceMessage: parent!,
|
||||
selfId: selfId,
|
||||
),
|
||||
|
||||
@@ -263,7 +263,6 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnswerReference(
|
||||
context: context,
|
||||
referenceMessage: referenceMessage,
|
||||
selfId: widget.selfId,
|
||||
),
|
||||
|
||||
@@ -78,39 +78,12 @@ class _OutsideDayColumn extends StatelessWidget {
|
||||
});
|
||||
|
||||
void _showOverflow(BuildContext context, List<Appointment> hidden) {
|
||||
showDetailsBottomSheet(
|
||||
_showAppointmentOverflowSheet(
|
||||
context,
|
||||
children: (sheetCtx) {
|
||||
final tiles = <Widget>[];
|
||||
for (var i = 0; i < hidden.length; i++) {
|
||||
if (i > 0) tiles.add(const Divider(height: 1));
|
||||
final apt = hidden[i];
|
||||
tiles.add(
|
||||
ListTile(
|
||||
leading: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: apt.color,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
apt.subject,
|
||||
style: isCrossedOut(apt)
|
||||
? const TextStyle(decoration: TextDecoration.lineThrough)
|
||||
: null,
|
||||
),
|
||||
subtitle: Text(_subtitleFor(apt)),
|
||||
onTap: () {
|
||||
Navigator.of(sheetCtx).pop();
|
||||
onAppointmentTap(apt);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return tiles;
|
||||
},
|
||||
hidden,
|
||||
onAppointmentTap: onAppointmentTap,
|
||||
isCrossedOut: isCrossedOut,
|
||||
subtitle: _subtitleFor,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -252,39 +252,12 @@ class _DayColumn extends StatelessWidget {
|
||||
) {
|
||||
final sorted = [...appointments]
|
||||
..sort((a, b) => a.startTime.compareTo(b.startTime));
|
||||
showDetailsBottomSheet(
|
||||
_showAppointmentOverflowSheet(
|
||||
context,
|
||||
children: (sheetContext) {
|
||||
final tiles = <Widget>[];
|
||||
for (var i = 0; i < sorted.length; i++) {
|
||||
if (i > 0) tiles.add(const Divider(height: 1));
|
||||
final apt = sorted[i];
|
||||
tiles.add(
|
||||
ListTile(
|
||||
leading: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: apt.color,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
apt.subject,
|
||||
style: isCrossedOut(apt)
|
||||
? const TextStyle(decoration: TextDecoration.lineThrough)
|
||||
: null,
|
||||
),
|
||||
subtitle: Text(_overflowSubtitle(apt)),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
onAppointmentTap(apt);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return tiles;
|
||||
},
|
||||
sorted,
|
||||
onAppointmentTap: onAppointmentTap,
|
||||
isCrossedOut: isCrossedOut,
|
||||
subtitle: _overflowSubtitle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -388,6 +361,52 @@ class _DayColumn extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared bottom sheet listing hidden appointments (used by both the in-grid
|
||||
/// and outside-hours overflow cells). [appointments] is rendered in the given
|
||||
/// order — callers pre-sort as needed.
|
||||
void _showAppointmentOverflowSheet(
|
||||
BuildContext context,
|
||||
List<Appointment> appointments, {
|
||||
required void Function(Appointment) onAppointmentTap,
|
||||
required bool Function(Appointment) isCrossedOut,
|
||||
required String Function(Appointment) subtitle,
|
||||
}) {
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
children: (sheetContext) {
|
||||
final tiles = <Widget>[];
|
||||
for (var i = 0; i < appointments.length; i++) {
|
||||
if (i > 0) tiles.add(const Divider(height: 1));
|
||||
final apt = appointments[i];
|
||||
tiles.add(
|
||||
ListTile(
|
||||
leading: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: apt.color,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
apt.subject,
|
||||
style: isCrossedOut(apt)
|
||||
? const TextStyle(decoration: TextDecoration.lineThrough)
|
||||
: null,
|
||||
),
|
||||
subtitle: Text(subtitle(apt)),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
onAppointmentTap(apt);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return tiles;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _CurrentTimeMarker extends StatelessWidget {
|
||||
final DateTime now;
|
||||
final PeriodLayout layout;
|
||||
|
||||
@@ -57,9 +57,6 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
_calendarKey.currentState?.jumpToDate(_initialDisplayDate());
|
||||
}
|
||||
|
||||
bool isOnInitialWeek() =>
|
||||
widget.state.startDate == _mondayOf(_initialDisplayDate());
|
||||
|
||||
List<Appointment> _appointments(TimetableState state) {
|
||||
final timetableSettings = context
|
||||
.watch<SettingsCubit>()
|
||||
|
||||
@@ -5,14 +5,12 @@ class AsyncDialogAction extends StatefulWidget {
|
||||
final AsyncActionCallback onConfirm;
|
||||
final String? cancelLabel;
|
||||
final AsyncErrorBuilder? errorBuilder;
|
||||
final ButtonStyle? confirmStyle;
|
||||
|
||||
const AsyncDialogAction({
|
||||
required this.confirmLabel,
|
||||
required this.onConfirm,
|
||||
this.cancelLabel = 'Abbrechen',
|
||||
this.errorBuilder,
|
||||
this.confirmStyle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -58,7 +56,6 @@ class _AsyncDialogActionState extends State<AsyncDialogAction> {
|
||||
child: Text(widget.cancelLabel!),
|
||||
),
|
||||
TextButton(
|
||||
style: widget.confirmStyle,
|
||||
onPressed: _controller.busy
|
||||
? null
|
||||
: () async {
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../routing/app_routes.dart';
|
||||
import 'details_bottom_sheet.dart';
|
||||
import 'file_pick.dart';
|
||||
|
||||
/// Result of the user's choice inside [showAvatarActionsSheet]. The sheet
|
||||
@@ -32,69 +33,50 @@ Future<AvatarSheetResult?> showAvatarActionsSheet(
|
||||
required bool allowRemove,
|
||||
}) async {
|
||||
AvatarSheetResult? result;
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
useSafeArea: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 16 + MediaQuery.viewInsetsOf(sheetContext).bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: const Text('Aus Galerie wählen'),
|
||||
onTap: () async {
|
||||
final bytes = await _pickAndCrop(
|
||||
sheetContext,
|
||||
FilePick.singleGalleryPick,
|
||||
);
|
||||
if (bytes == null || !sheetContext.mounted) return;
|
||||
result = AvatarUploadResult(bytes);
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: const Text('Foto aufnehmen'),
|
||||
onTap: () async {
|
||||
final bytes = await _pickAndCrop(
|
||||
sheetContext,
|
||||
FilePick.cameraPick,
|
||||
);
|
||||
if (bytes == null || !sheetContext.mounted) return;
|
||||
result = AvatarUploadResult(bytes);
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
),
|
||||
if (allowRemove) ...[
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.delete_outline,
|
||||
color: Theme.of(sheetContext).colorScheme.error,
|
||||
),
|
||||
title: Text(
|
||||
'Profilbild entfernen',
|
||||
style: TextStyle(
|
||||
color: Theme.of(sheetContext).colorScheme.error,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
result = const AvatarRemoveResult();
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
await showDetailsBottomSheet(
|
||||
context,
|
||||
children: (sheetContext) => [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: const Text('Aus Galerie wählen'),
|
||||
onTap: () async {
|
||||
final bytes = await _pickAndCrop(
|
||||
sheetContext,
|
||||
FilePick.singleGalleryPick,
|
||||
);
|
||||
if (bytes == null || !sheetContext.mounted) return;
|
||||
result = AvatarUploadResult(bytes);
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: const Text('Foto aufnehmen'),
|
||||
onTap: () async {
|
||||
final bytes = await _pickAndCrop(sheetContext, FilePick.cameraPick);
|
||||
if (bytes == null || !sheetContext.mounted) return;
|
||||
result = AvatarUploadResult(bytes);
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
),
|
||||
if (allowRemove) ...[
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.delete_outline,
|
||||
color: Theme.of(sheetContext).colorScheme.error,
|
||||
),
|
||||
title: Text(
|
||||
'Profilbild entfernen',
|
||||
style: TextStyle(color: Theme.of(sheetContext).colorScheme.error),
|
||||
),
|
||||
onTap: () {
|
||||
result = const AvatarRemoveResult();
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../routing/app_routes.dart';
|
||||
import 'details_bottom_sheet.dart';
|
||||
import 'file_pick.dart';
|
||||
|
||||
/// Bottom sheet with "from gallery" and "take photo" actions for choosing a
|
||||
@@ -13,44 +14,30 @@ import 'file_pick.dart';
|
||||
/// cancelled everything.
|
||||
Future<Uint8List?> showChatBackgroundPickerSheet(BuildContext context) async {
|
||||
Uint8List? result;
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
useSafeArea: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 16 + MediaQuery.viewInsetsOf(sheetContext).bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: const Text('Aus Galerie wählen'),
|
||||
onTap: () async {
|
||||
final bytes = await _pickRaw(FilePick.singleGalleryPick);
|
||||
if (bytes == null || !sheetContext.mounted) return;
|
||||
result = bytes;
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: const Text('Foto aufnehmen'),
|
||||
onTap: () async {
|
||||
final bytes = await _pickRaw(FilePick.cameraPick);
|
||||
if (bytes == null || !sheetContext.mounted) return;
|
||||
result = bytes;
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
await showDetailsBottomSheet(
|
||||
context,
|
||||
children: (sheetContext) => [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: const Text('Aus Galerie wählen'),
|
||||
onTap: () async {
|
||||
final bytes = await _pickRaw(FilePick.singleGalleryPick);
|
||||
if (bytes == null || !sheetContext.mounted) return;
|
||||
result = bytes;
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: const Text('Foto aufnehmen'),
|
||||
onTap: () async {
|
||||
final bytes = await _pickRaw(FilePick.cameraPick);
|
||||
if (bytes == null || !sheetContext.mounted) return;
|
||||
result = bytes;
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ class CacheView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CacheViewState extends State<CacheView> {
|
||||
final Localstore storage = Localstore.instance;
|
||||
late Future<Map<String, dynamic>?> files;
|
||||
|
||||
@override
|
||||
|
||||
@@ -15,8 +15,7 @@ class DebugTile {
|
||||
context.read<SettingsCubit>().val().devToolsEnabled &&
|
||||
(onlyInDebug ? kDebugMode : true);
|
||||
|
||||
Widget jsonData(Map<String, dynamic> data, {bool ignoreConfig = false}) =>
|
||||
callback(
|
||||
Widget jsonData(Map<String, dynamic> data) => callback(
|
||||
title: 'JSON daten anzeigen',
|
||||
onTab: () => JsonViewer.asDialog(context, data),
|
||||
);
|
||||
|
||||
@@ -4,21 +4,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../utils/clipboard_helper.dart';
|
||||
|
||||
class JsonViewer extends StatelessWidget {
|
||||
final String title;
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
const JsonViewer({super.key, required this.title, required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: SingleChildScrollView(
|
||||
scrollDirection: Axis.vertical,
|
||||
child: Text(format(data)),
|
||||
),
|
||||
);
|
||||
|
||||
class JsonViewer {
|
||||
static final _encoder = const JsonEncoder.withIndent(' ');
|
||||
|
||||
static String format(Map<String, dynamic> jsonInput) =>
|
||||
|
||||
Reference in New Issue
Block a user