refactored HTTP error handling and streamlined UI components by centralizing shared logic and removing redundant parameters

This commit is contained in:
2026-07-13 22:58:02 +02:00
parent d7536ea5d0
commit 8274dd46cd
28 changed files with 284 additions and 447 deletions
-1
View File
@@ -1 +0,0 @@
class ApiRequest {}
+3 -3
View File
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
import '../api_error.dart'; import '../api_error.dart';
import '../http_errors.dart';
import '../marianumcloud/talk/talk_error.dart'; import '../marianumcloud/talk/talk_error.dart';
import 'app_exception.dart'; import 'app_exception.dart';
import 'auth_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). /// status plus a trimmed body preview (same format as the Talk API errors).
AppException _dynamiteToAppException(DynamiteApiException error) { AppException _dynamiteToAppException(DynamiteApiException error) {
final status = error.statusCode; final status = error.statusCode;
final body = error.body.replaceAll(RegExp(r'\s+'), ' ').trim(); final preview = previewBody(error.body);
final preview = body.length > 500 ? '${body.substring(0, 500)}' : body; final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
final detail = body.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
switch (status) { switch (status) {
case 401: case 401:
return AuthException.unauthorized(technicalDetails: detail); return AuthException.unauthorized(technicalDetails: detail);
+53
View File
@@ -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:convert';
import 'dart:developer'; import 'dart:developer';
import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../model/account_data.dart'; import '../../../model/account_data.dart';
import '../../../model/endpoint_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/parse_exception.dart';
import '../../errors/server_exception.dart'; import '../../http_errors.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
/// Mix of two Nextcloud surfaces: /// Mix of two Nextcloud surfaces:
@@ -42,30 +37,17 @@ Future<http.Response> _send(
) async { ) async {
final headers = NextcloudOcs.headers(); final headers = NextcloudOcs.headers();
final http.Response response; final response = (await sendGuarded(
try { 'Cloud $uri',
response = await perform(uri, headers); () => 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 status = response.statusCode; final status = response.statusCode;
if (status >= 200 && status < 300) return response; if (status >= 200 && status < 300) return response;
final body = response.body.replaceAll(RegExp(r'\s+'), ' ').trim(); final detail = httpErrorDetail('Cloud $uri', response.body, status);
final preview = body.length > 500 ? '${body.substring(0, 500)}' : body;
final detail = body.isEmpty
? 'Cloud $uri -> HTTP $status'
: 'Cloud $uri -> HTTP $status body=$preview';
log(detail); log(detail);
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail); throwForStatus(status, detail);
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
if (status == 404) throw NotFoundException(technicalDetails: detail);
throw ServerException(statusCode: status, technicalDetails: detail);
} }
class SetUserAvatar { class SetUserAvatar {
@@ -1,10 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../errors/network_exception.dart';
import '../../../errors/server_exception.dart'; import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart'; import 'get_chat_params.dart';
import 'get_chat_response.dart'; import 'get_chat_response.dart';
@@ -40,18 +37,12 @@ class LongPollChat {
); );
final headers = NextcloudOcs.headers(); final headers = NextcloudOcs.headers();
final http.Response response; final response = (await sendGuarded(
try { 'LongPollChat $uri',
response = await http () => http
.get(uri, headers: headers) .get(uri, headers: headers)
.timeout(Duration(seconds: timeoutSeconds + 15)); .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}');
}
final status = response.statusCode; final status = response.statusCode;
if (status == 304) return null; if (status == 304) return null;
+13 -40
View File
@@ -1,29 +1,20 @@
import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../api_params.dart'; import '../../api_params.dart';
import '../../api_request.dart';
import '../../api_response.dart'; import '../../api_response.dart';
import '../../errors/auth_exception.dart';
import '../../errors/network_exception.dart'; import '../../errors/network_exception.dart';
import '../../errors/not_found_exception.dart';
import '../../errors/parse_exception.dart'; import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart'; import '../../http_errors.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
enum TalkApiMethod { get, post, put, delete } abstract class TalkApi<T extends ApiResponse?> {
abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
String path; String path;
ApiParams? body; ApiParams? body;
Map<String, String>? headers; Map<String, String>? headers;
Map<String, dynamic>? getParameters; Map<String, dynamic>? getParameters;
http.Response? response;
TalkApi(this.path, this.body, {this.headers, this.getParameters}); TalkApi(this.path, this.body, {this.headers, this.getParameters});
Future<http.Response>? request( Future<http.Response>? request(
@@ -40,22 +31,15 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
); );
final mergedHeaders = {...NextcloudOcs.headers(), ...?headers}; final mergedHeaders = {...NextcloudOcs.headers(), ...?headers};
final http.Response data; final data = await sendGuarded(
try { 'Talk $endpoint',
final raw = await request(endpoint, body, mergedHeaders); () => request(endpoint, body, mergedHeaders),
if (raw == null) { );
throw const NetworkException( if (data == null) {
userMessage: 'Keine Antwort vom Talk-Server erhalten.', throw const NetworkException(
technicalDetails: 'Talk request returned null', 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 status = data.statusCode; 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, // Talk's OCS errors carry the real reason in the body (expired session,
// removed participant, ...); include a trimmed preview so the dialog and // removed participant, ...); include a trimmed preview so the dialog and
// logs surface the cause instead of just the bare status code. // logs surface the cause instead of just the bare status code.
final body = data.body.replaceAll(RegExp(r'\s+'), ' ').trim(); final detail = httpErrorDetail('Talk $endpoint', data.body, status);
final preview = body.length > 500 ? '${body.substring(0, 500)}' : body;
final detail = body.isEmpty
? 'Talk $endpoint -> HTTP $status'
: 'Talk $endpoint -> HTTP $status body=$preview';
log(detail); log(detail);
if (status == 401) { throwForStatus(status, detail);
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);
} }
try { try {
+1 -2
View File
@@ -2,10 +2,9 @@ import 'package:nextcloud/nextcloud.dart';
import '../../../model/account_data.dart'; import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart'; import '../../../model/endpoint_data.dart';
import '../../api_request.dart';
import '../../api_response.dart'; import '../../api_response.dart';
abstract class WebdavApi<T> extends ApiRequest { abstract class WebdavApi<T> {
T genericParams; T genericParams;
WebdavApi(this.genericParams) { WebdavApi(this.genericParams) {
+8 -27
View File
@@ -1,21 +1,16 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:jiffy/jiffy.dart';
import '../api_request.dart';
import '../errors/network_exception.dart'; import '../errors/network_exception.dart';
import '../errors/parse_exception.dart'; import '../errors/parse_exception.dart';
import '../errors/server_exception.dart'; import '../errors/server_exception.dart';
import '../http_errors.dart';
abstract class MhslApi<T> extends ApiRequest { abstract class MhslApi<T> {
String subpath; String subpath;
MhslApi(this.subpath); MhslApi(this.subpath);
http.Response? response;
Future<http.Response>? request(Uri uri); Future<http.Response>? request(Uri uri);
T assemble(String raw); T assemble(String raw);
@@ -24,22 +19,12 @@ abstract class MhslApi<T> extends ApiRequest {
'https://mhsl.eu/marianum/marianummobile/$subpath', 'https://mhsl.eu/marianum/marianummobile/$subpath',
); );
final http.Response data; final data = await sendGuarded('mhsl $subpath', () => request(endpoint));
try { if (data == null) {
final raw = await request(endpoint); throw const NetworkException(
if (raw == null) { userMessage: 'Keine Antwort vom MHSL-Dienst erhalten.',
throw const NetworkException( technicalDetails: 'mhsl request returned null',
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}');
} }
if (data.statusCode > 299) { if (data.statusCode > 299) {
@@ -55,8 +40,4 @@ abstract class MhslApi<T> extends ApiRequest {
throw ParseException(technicalDetails: 'mhsl $subpath assemble: $e'); 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
View File
@@ -256,9 +256,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (totalTabs != _knownTotalTabs) { if (totalTabs != _knownTotalTabs) {
var targetIndex = currentIndex; var targetIndex = currentIndex;
if (_userOnLastTab) { if (_userOnLastTab || currentIndex >= totalTabs) {
targetIndex = totalTabs - 1;
} else if (currentIndex >= totalTabs) {
targetIndex = totalTabs - 1; targetIndex = totalTabs - 1;
} }
// Replace the controller atomically: a stale index past the new // 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, String? statusText,
}) = _LoadableState<TState>; }) = _LoadableState<TState>;
bool _hasError() => error != null; bool showContent() => data != null;
bool _hasData() => data != null;
bool showPrimaryLoading() => isLoading && !_hasData();
bool showBackgroundLoading() => isLoading && _hasData();
bool showErrorBar() => _hasError() && _hasData();
bool showError() => _hasError() && !_hasData();
bool showContent() => _hasData();
} }
+2 -10
View File
@@ -36,7 +36,6 @@ class DownloadManager {
final Map<String, DownloadJob> _jobs = {}; // keyed by remotePath final Map<String, DownloadJob> _jobs = {}; // keyed by remotePath
final Map<String, DownloadJob> _byTaskId = {}; final Map<String, DownloadJob> _byTaskId = {};
final Map<String, bd.DownloadTask> _taskById = {};
/// All jobs the user should currently see in the downloads tray/overview: /// All jobs the user should currently see in the downloads tray/overview:
/// everything that is in progress or finished-but-not-yet-opened (failed /// everything that is in progress or finished-but-not-yet-opened (failed
@@ -134,7 +133,6 @@ class DownloadManager {
)..taskId = task.taskId; )..taskId = task.taskId;
_jobs[remotePath] = job; _jobs[remotePath] = job;
_byTaskId[task.taskId] = job; _byTaskId[task.taskId] = job;
_taskById[task.taskId] = task;
_refreshVisible(); _refreshVisible();
final ok = await bd.FileDownloader().enqueue(task); final ok = await bd.FileDownloader().enqueue(task);
@@ -217,10 +215,7 @@ class DownloadManager {
job.status.value = const DownloadCancelled(); job.status.value = const DownloadCancelled();
} }
_jobs.remove(job.remotePath); _jobs.remove(job.remotePath);
if (taskId != null) { if (taskId != null) _byTaskId.remove(taskId);
_byTaskId.remove(taskId);
_taskById.remove(taskId);
}
scheduleMicrotask(job.dispose); scheduleMicrotask(job.dispose);
} }
_refreshVisible(); _refreshVisible();
@@ -314,10 +309,7 @@ class DownloadManager {
void _remove(DownloadJob job) { void _remove(DownloadJob job) {
_jobs.remove(job.remotePath); _jobs.remove(job.remotePath);
final taskId = job.taskId; final taskId = job.taskId;
if (taskId != null) { if (taskId != null) _byTaskId.remove(taskId);
_byTaskId.remove(taskId);
_taskById.remove(taskId);
}
_refreshVisible(); _refreshVisible();
scheduleMicrotask(job.dispose); scheduleMicrotask(job.dispose);
} }
+11 -14
View File
@@ -171,21 +171,18 @@ class _FileElementState extends State<FileElement>
); );
} }
Future<void> _delete() async { void _delete() {
if (guardDemoAction(context)) return; if (guardDemoAction(context)) return;
await showDialog<void>( ConfirmDialog(
context: context, title: 'Element löschen?',
builder: (context) => ConfirmDialog( content: 'Das Element wird unwiederruflich gelöscht.',
title: 'Element löschen?', confirmButton: 'Löschen',
content: 'Das Element wird unwiederruflich gelöscht.', onConfirmAsync: () async {
confirmButton: 'Löschen', final webdav = await WebdavApi.webdav;
onConfirmAsync: () async { await webdav.delete(PathUri.parse(widget.file.path));
final webdav = await WebdavApi.webdav; widget.refetch();
await webdav.delete(PathUri.parse(widget.file.path)); },
widget.refetch(); ).asDialog(context);
},
),
);
} }
void _showActionSheet() { void _showActionSheet() {
@@ -86,20 +86,11 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
trailing: DropdownButton<ChatBackgroundType>( trailing: DropdownButton<ChatBackgroundType>(
value: s.type, value: s.type,
icon: const Icon(Icons.arrow_drop_down), icon: const Icon(Icons.arrow_drop_down),
items: ChatBackgroundType.values items: _iconDropdownItems(
.map( ChatBackgroundType.values,
(e) => DropdownMenuItem<ChatBackgroundType>( _typeIcon,
value: e, _typeLabel,
child: Row( ),
children: [
Icon(_typeIcon(e)),
const SizedBox(width: 10),
Text(_typeLabel(e)),
],
),
),
)
.toList(),
onChanged: (e) => _onTypeChanged(context, settings, e!), onChanged: (e) => _onTypeChanged(context, settings, e!),
), ),
), ),
@@ -142,20 +133,11 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
trailing: DropdownButton<ChatBackgroundFit>( trailing: DropdownButton<ChatBackgroundFit>(
value: s.fit, value: s.fit,
icon: const Icon(Icons.arrow_drop_down), icon: const Icon(Icons.arrow_drop_down),
items: ChatBackgroundFit.values items: _iconDropdownItems(
.map( ChatBackgroundFit.values,
(e) => DropdownMenuItem<ChatBackgroundFit>( _fitIcon,
value: e, _fitLabel,
child: Row( ),
children: [
Icon(_fitIcon(e)),
const SizedBox(width: 10),
Text(_fitLabel(e)),
],
),
),
)
.toList(),
onChanged: (e) { onChanged: (e) {
Haptics.selection(); Haptics.selection();
settings.val(write: true).chatBackgroundSettings.fit = settings.val(write: true).chatBackgroundSettings.fit =
@@ -280,6 +262,27 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
cs.type = ChatBackgroundType.color; 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) { IconData _typeIcon(ChatBackgroundType type) => switch (type) {
ChatBackgroundType.pattern => Icons.texture_outlined, ChatBackgroundType.pattern => Icons.texture_outlined,
ChatBackgroundType.image => Icons.image_outlined, ChatBackgroundType.image => Icons.image_outlined,
@@ -330,7 +333,6 @@ class _Preview extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_bubble( _bubble(
context,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
color: remoteColor, color: remoteColor,
text: 'Wie gefällt dir der neue Hintergrund?', text: 'Wie gefällt dir der neue Hintergrund?',
@@ -338,7 +340,6 @@ class _Preview extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_bubble( _bubble(
context,
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
color: selfColor, color: selfColor,
text: 'Sieht richtig gut aus! 🎉', text: 'Sieht richtig gut aus! 🎉',
@@ -352,8 +353,7 @@ class _Preview extends StatelessWidget {
); );
} }
Widget _bubble( Widget _bubble({
BuildContext context, {
required Alignment alignment, required Alignment alignment,
required Color color, required Color color,
required String text, required String text,
-3
View File
@@ -245,7 +245,6 @@ class _ChatViewState extends State<ChatView> with RouteAware {
lastDate = elementDate; lastDate = elementDate;
messages.add( messages.add(
ChatBubble( ChatBubble(
context: context,
isSender: false, isSender: false,
bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), bubbleData: GetChatResponseObject.getDateDummy(element.timestamp),
chatData: widget.room, chatData: widget.room,
@@ -265,7 +264,6 @@ class _ChatViewState extends State<ChatView> with RouteAware {
messages.add( messages.add(
ChatBubble( ChatBubble(
context: context,
isSender: isSender:
element.actorId == widget.selfId && element.actorId == widget.selfId &&
(element.messageType == (element.messageType ==
@@ -287,7 +285,6 @@ class _ChatViewState extends State<ChatView> with RouteAware {
messages.insert( messages.insert(
0, 0,
ChatBubble( ChatBubble(
context: context,
isSender: false, isSender: false,
bubbleData: GetChatResponseObject.getTextDummy( bubbleData: GetChatResponseObject.getTextDummy(
'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. ' 'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. '
@@ -24,29 +24,23 @@ class ChatBubbleStyles {
alignment: Alignment.center, alignment: Alignment.center,
); );
BubbleStyle getRemoteStyle(bool seamless) { BubbleStyle getRemoteStyle() => BubbleStyle(
var color = AppTheme.isDarkMode(context) nip: BubbleNip.leftTop,
color: AppTheme.isDarkMode(context)
? const Color(0xff202c33) ? const Color(0xff202c33)
: Colors.white; : Colors.white,
return BubbleStyle( elevation: 1,
nip: BubbleNip.leftTop, margin: const BubbleEdges.only(bottom: 10, left: 10, right: 50),
color: seamless ? Colors.transparent : color, alignment: Alignment.topLeft,
elevation: seamless ? 0 : 1, );
margin: const BubbleEdges.only(bottom: 10, left: 10, right: 50),
alignment: Alignment.topLeft,
);
}
BubbleStyle getSelfStyle(bool seamless) { BubbleStyle getSelfStyle() => BubbleStyle(
var color = AppTheme.isDarkMode(context) nip: BubbleNip.rightBottom,
color: AppTheme.isDarkMode(context)
? const Color(0xff005c4b) ? const Color(0xff005c4b)
: const Color(0xffd3d3d3); : const Color(0xffd3d3d3),
return BubbleStyle( elevation: 1,
nip: BubbleNip.rightBottom, margin: const BubbleEdges.only(bottom: 10, right: 10, left: 50),
color: seamless ? Colors.transparent : color, alignment: Alignment.topRight,
elevation: seamless ? 0 : 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'; import '../data/chat_bubble_styles.dart';
class AnswerReference extends StatelessWidget { class AnswerReference extends StatelessWidget {
final BuildContext context;
final GetChatResponseObject referenceMessage; final GetChatResponseObject referenceMessage;
final String? selfId; final String? selfId;
const AnswerReference({ const AnswerReference({
required this.context,
required this.referenceMessage, required this.referenceMessage,
required this.selfId, required this.selfId,
super.key, super.key,
@@ -20,8 +18,8 @@ class AnswerReference extends StatelessWidget {
final style = ChatBubbleStyles(context); final style = ChatBubbleStyles(context);
final isSelf = referenceMessage.actorId == selfId; final isSelf = referenceMessage.actorId == selfId;
final accent = isSelf final accent = isSelf
? style.getSelfStyle(false).color!.withGreen(200) ? style.getSelfStyle().color!.withGreen(200)
: style.getRemoteStyle(false).color!.withWhite(200); : style.getRemoteStyle().color!.withWhite(200);
return DecoratedBox( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: accent.withValues(alpha: 0.2), color: accent.withValues(alpha: 0.2),
+2 -5
View File
@@ -23,7 +23,6 @@ import 'highlighted_linkify.dart';
enum SearchHighlight { none, secondary, active } enum SearchHighlight { none, secondary, active }
class ChatBubble extends StatefulWidget { class ChatBubble extends StatefulWidget {
final BuildContext context;
final bool isSender; final bool isSender;
final GetChatResponseObject bubbleData; final GetChatResponseObject bubbleData;
final GetRoomResponseObject chatData; final GetRoomResponseObject chatData;
@@ -40,7 +39,6 @@ class ChatBubble extends StatefulWidget {
final SearchHighlight matchHighlight; final SearchHighlight matchHighlight;
const ChatBubble({ const ChatBubble({
required this.context,
required this.isSender, required this.isSender,
required this.bubbleData, required this.bubbleData,
required this.chatData, required this.chatData,
@@ -123,8 +121,8 @@ class _ChatBubbleState extends State<ChatBubble>
base = styles.getSystemStyle(); base = styles.getSystemStyle();
} else { } else {
base = widget.isSender base = widget.isSender
? styles.getSelfStyle(false) ? styles.getSelfStyle()
: styles.getRemoteStyle(false); : styles.getRemoteStyle();
} }
switch (widget.matchHighlight) { switch (widget.matchHighlight) {
case SearchHighlight.none: case SearchHighlight.none:
@@ -366,7 +364,6 @@ class _BubbleContent extends StatelessWidget {
bubbleData.messageType == bubbleData.messageType ==
GetRoomResponseObjectMessageType.comment) ...[ GetRoomResponseObjectMessageType.comment) ...[
AnswerReference( AnswerReference(
context: context,
referenceMessage: parent!, referenceMessage: parent!,
selfId: selfId, selfId: selfId,
), ),
@@ -263,7 +263,6 @@ class _ChatTextfieldState extends State<ChatTextfield> {
children: [ children: [
Expanded( Expanded(
child: AnswerReference( child: AnswerReference(
context: context,
referenceMessage: referenceMessage, referenceMessage: referenceMessage,
selfId: widget.selfId, selfId: widget.selfId,
), ),
@@ -78,39 +78,12 @@ class _OutsideDayColumn extends StatelessWidget {
}); });
void _showOverflow(BuildContext context, List<Appointment> hidden) { void _showOverflow(BuildContext context, List<Appointment> hidden) {
showDetailsBottomSheet( _showAppointmentOverflowSheet(
context, context,
children: (sheetCtx) { hidden,
final tiles = <Widget>[]; onAppointmentTap: onAppointmentTap,
for (var i = 0; i < hidden.length; i++) { isCrossedOut: isCrossedOut,
if (i > 0) tiles.add(const Divider(height: 1)); subtitle: _subtitleFor,
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;
},
); );
} }
@@ -252,39 +252,12 @@ class _DayColumn extends StatelessWidget {
) { ) {
final sorted = [...appointments] final sorted = [...appointments]
..sort((a, b) => a.startTime.compareTo(b.startTime)); ..sort((a, b) => a.startTime.compareTo(b.startTime));
showDetailsBottomSheet( _showAppointmentOverflowSheet(
context, context,
children: (sheetContext) { sorted,
final tiles = <Widget>[]; onAppointmentTap: onAppointmentTap,
for (var i = 0; i < sorted.length; i++) { isCrossedOut: isCrossedOut,
if (i > 0) tiles.add(const Divider(height: 1)); subtitle: _overflowSubtitle,
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;
},
); );
} }
@@ -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 { class _CurrentTimeMarker extends StatelessWidget {
final DateTime now; final DateTime now;
final PeriodLayout layout; final PeriodLayout layout;
@@ -57,9 +57,6 @@ class TimetableCalendarViewState extends State<TimetableCalendarView> {
_calendarKey.currentState?.jumpToDate(_initialDisplayDate()); _calendarKey.currentState?.jumpToDate(_initialDisplayDate());
} }
bool isOnInitialWeek() =>
widget.state.startDate == _mondayOf(_initialDisplayDate());
List<Appointment> _appointments(TimetableState state) { List<Appointment> _appointments(TimetableState state) {
final timetableSettings = context final timetableSettings = context
.watch<SettingsCubit>() .watch<SettingsCubit>()
@@ -5,14 +5,12 @@ class AsyncDialogAction extends StatefulWidget {
final AsyncActionCallback onConfirm; final AsyncActionCallback onConfirm;
final String? cancelLabel; final String? cancelLabel;
final AsyncErrorBuilder? errorBuilder; final AsyncErrorBuilder? errorBuilder;
final ButtonStyle? confirmStyle;
const AsyncDialogAction({ const AsyncDialogAction({
required this.confirmLabel, required this.confirmLabel,
required this.onConfirm, required this.onConfirm,
this.cancelLabel = 'Abbrechen', this.cancelLabel = 'Abbrechen',
this.errorBuilder, this.errorBuilder,
this.confirmStyle,
super.key, super.key,
}); });
@@ -58,7 +56,6 @@ class _AsyncDialogActionState extends State<AsyncDialogAction> {
child: Text(widget.cancelLabel!), child: Text(widget.cancelLabel!),
), ),
TextButton( TextButton(
style: widget.confirmStyle,
onPressed: _controller.busy onPressed: _controller.busy
? null ? null
: () async { : () async {
+44 -62
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '../routing/app_routes.dart'; import '../routing/app_routes.dart';
import 'details_bottom_sheet.dart';
import 'file_pick.dart'; import 'file_pick.dart';
/// Result of the user's choice inside [showAvatarActionsSheet]. The sheet /// Result of the user's choice inside [showAvatarActionsSheet]. The sheet
@@ -32,69 +33,50 @@ Future<AvatarSheetResult?> showAvatarActionsSheet(
required bool allowRemove, required bool allowRemove,
}) async { }) async {
AvatarSheetResult? result; AvatarSheetResult? result;
await showModalBottomSheet<void>( await showDetailsBottomSheet(
context: context, context,
isScrollControlled: true, children: (sheetContext) => [
showDragHandle: true, ListTile(
useSafeArea: true, leading: const Icon(Icons.photo_library_outlined),
builder: (sheetContext) => SafeArea( title: const Text('Aus Galerie wählen'),
child: SingleChildScrollView( onTap: () async {
padding: EdgeInsets.only( final bytes = await _pickAndCrop(
bottom: 16 + MediaQuery.viewInsetsOf(sheetContext).bottom, sheetContext,
), FilePick.singleGalleryPick,
child: Column( );
mainAxisSize: MainAxisSize.min, if (bytes == null || !sheetContext.mounted) return;
crossAxisAlignment: CrossAxisAlignment.stretch, result = AvatarUploadResult(bytes);
children: [ Navigator.of(sheetContext).pop();
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();
},
),
],
],
),
), ),
), 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; return result;
} }
+24 -37
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '../routing/app_routes.dart'; import '../routing/app_routes.dart';
import 'details_bottom_sheet.dart';
import 'file_pick.dart'; import 'file_pick.dart';
/// Bottom sheet with "from gallery" and "take photo" actions for choosing a /// Bottom sheet with "from gallery" and "take photo" actions for choosing a
@@ -13,44 +14,30 @@ import 'file_pick.dart';
/// cancelled everything. /// cancelled everything.
Future<Uint8List?> showChatBackgroundPickerSheet(BuildContext context) async { Future<Uint8List?> showChatBackgroundPickerSheet(BuildContext context) async {
Uint8List? result; Uint8List? result;
await showModalBottomSheet<void>( await showDetailsBottomSheet(
context: context, context,
isScrollControlled: true, children: (sheetContext) => [
showDragHandle: true, ListTile(
useSafeArea: true, leading: const Icon(Icons.photo_library_outlined),
builder: (sheetContext) => SafeArea( title: const Text('Aus Galerie wählen'),
child: SingleChildScrollView( onTap: () async {
padding: EdgeInsets.only( final bytes = await _pickRaw(FilePick.singleGalleryPick);
bottom: 16 + MediaQuery.viewInsetsOf(sheetContext).bottom, if (bytes == null || !sheetContext.mounted) return;
), result = bytes;
child: Column( Navigator.of(sheetContext).pop();
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();
},
),
],
),
), ),
), 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; return result;
} }
-1
View File
@@ -33,7 +33,6 @@ class CacheView extends StatefulWidget {
} }
class _CacheViewState extends State<CacheView> { class _CacheViewState extends State<CacheView> {
final Localstore storage = Localstore.instance;
late Future<Map<String, dynamic>?> files; late Future<Map<String, dynamic>?> files;
@override @override
+1 -2
View File
@@ -15,8 +15,7 @@ class DebugTile {
context.read<SettingsCubit>().val().devToolsEnabled && context.read<SettingsCubit>().val().devToolsEnabled &&
(onlyInDebug ? kDebugMode : true); (onlyInDebug ? kDebugMode : true);
Widget jsonData(Map<String, dynamic> data, {bool ignoreConfig = false}) => Widget jsonData(Map<String, dynamic> data) => callback(
callback(
title: 'JSON daten anzeigen', title: 'JSON daten anzeigen',
onTab: () => JsonViewer.asDialog(context, data), onTab: () => JsonViewer.asDialog(context, data),
); );
+1 -15
View File
@@ -4,21 +4,7 @@ import 'package:flutter/material.dart';
import '../../utils/clipboard_helper.dart'; import '../../utils/clipboard_helper.dart';
class JsonViewer extends StatelessWidget { class JsonViewer {
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)),
),
);
static final _encoder = const JsonEncoder.withIndent(' '); static final _encoder = const JsonEncoder.withIndent(' ');
static String format(Map<String, dynamic> jsonInput) => static String format(Map<String, dynamic> jsonInput) =>