Files
Qlyra/lib/frontend/screens/calls/komet_hub.dart
T

679 lines
18 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/calls/call_session.dart';
import '../../../core/games/checkers.dart';
import '../../../l10n/app_localizations.dart';
import '../../widgets/sheet_helpers.dart';
import '../../../core/config/app_fonts.dart';
Future<void> showKometHub(
BuildContext context, {
required CallSession session,
required ColorScheme scheme,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: scheme.surfaceContainerHigh,
shape: kSheetShape,
builder: (_) => Theme(
data: Theme.of(context).copyWith(colorScheme: scheme),
child: _KometHub(session: session),
),
);
}
enum _HubPage { menu, chat, games, checkers }
class _KometHub extends StatefulWidget {
final CallSession session;
const _KometHub({required this.session});
@override
State<_KometHub> createState() => _KometHubState();
}
class _KometHubState extends State<_KometHub> {
_HubPage _page = _HubPage.menu;
void _go(_HubPage page) => setState(() => _page = page);
void _back() {
switch (_page) {
case _HubPage.menu:
Navigator.of(context).maybePop();
break;
case _HubPage.checkers:
_go(_HubPage.games);
break;
case _HubPage.chat:
case _HubPage.games:
_go(_HubPage.menu);
break;
}
}
String get _title {
final l10n = AppLocalizations.of(context)!;
switch (_page) {
case _HubPage.menu:
return l10n.hubTitleMenu;
case _HubPage.chat:
return l10n.hubChatPageTitle;
case _HubPage.games:
return l10n.hubGamesTitle;
case _HubPage.checkers:
return l10n.hubCheckersTitle;
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.78,
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_header(cs),
Flexible(child: _body(cs)),
],
),
),
),
);
}
Widget _header(ColorScheme cs) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 16, 8),
child: Row(
children: [
IconButton(
onPressed: _back,
icon: Icon(
_page == _HubPage.menu ? Symbols.close : Symbols.arrow_back,
color: cs.onSurface,
),
),
const SizedBox(width: 4),
Text(
_title,
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
fontWeight: FontWeight.w700,
fontFamily: displayFontOf(context),
),
),
],
),
);
}
Widget _body(ColorScheme cs) {
switch (_page) {
case _HubPage.menu:
return _menu(cs);
case _HubPage.games:
return _games(cs);
case _HubPage.chat:
return _KometChatView(session: widget.session);
case _HubPage.checkers:
return _CheckersView(session: widget.session);
}
}
Widget _menu(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_tile(
cs,
Symbols.forum,
l10n.hubChatTileTitle,
l10n.hubChatTileSubtitle,
() => _go(_HubPage.chat),
),
_tile(
cs,
Symbols.stadia_controller,
l10n.hubGamesTitle,
l10n.hubGamesTileSubtitle,
() => _go(_HubPage.games),
),
const SizedBox(height: 12),
],
);
}
Widget _games(ColorScheme cs) {
final l10n = AppLocalizations.of(context)!;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_tile(
cs,
Symbols.grid_on,
l10n.hubCheckersTitle,
l10n.hubCheckersTileSubtitle,
() => _go(_HubPage.checkers),
),
_tile(
cs,
Symbols.more_horiz,
l10n.hubMoreSoonTitle,
l10n.hubMoreSoonSubtitle,
null,
),
const SizedBox(height: 12),
],
);
}
Widget _tile(
ColorScheme cs,
IconData icon,
String title,
String subtitle,
VoidCallback? onTap,
) {
final enabled = onTap != null;
return ListTile(
onTap: onTap,
enabled: enabled,
leading: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(icon, color: enabled ? cs.primary : cs.onSurfaceVariant),
),
title: Text(
title,
style: TextStyle(
color: enabled ? cs.onSurface : cs.onSurfaceVariant,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
subtitle: Text(
subtitle,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
trailing: enabled
? Icon(Symbols.chevron_right, color: cs.onSurfaceVariant)
: null,
);
}
}
class _KometChatView extends StatefulWidget {
final CallSession session;
const _KometChatView({required this.session});
@override
State<_KometChatView> createState() => _KometChatViewState();
}
class _KometChatViewState extends State<_KometChatView> {
final TextEditingController _controller = TextEditingController();
final ScrollController _scroll = ScrollController();
StreamSubscription<CallChatMessage>? _sub;
@override
void initState() {
super.initState();
_sub = widget.session.chatMessages.listen((_) {
if (mounted) setState(() {});
_scrollToBottom();
});
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom());
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_scroll.hasClients) return;
_scroll.animateTo(
_scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
);
});
}
void _send() {
final text = _controller.text.trim();
if (text.isEmpty) return;
widget.session.sendChatMessage(text);
_controller.clear();
}
@override
void dispose() {
_sub?.cancel();
_controller.dispose();
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final cs = Theme.of(context).colorScheme;
final messages = widget.session.chatLog;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 6),
child: Row(
children: [
Icon(Symbols.lock, size: 16, color: cs.primary, fill: 1),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.hubChatPrivacyNote,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
),
],
),
),
Flexible(
child: messages.isEmpty
? _empty(cs)
: ListView.builder(
controller: _scroll,
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
itemCount: messages.length,
itemBuilder: (_, i) => _bubble(cs, messages[i]),
),
),
_input(cs),
],
);
}
Widget _empty(ColorScheme cs) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
AppLocalizations.of(context)!.hubChatEmpty,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14),
),
),
);
}
Widget _bubble(ColorScheme cs, CallChatMessage message) {
return Align(
alignment: message.mine ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 3, horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.72,
),
decoration: BoxDecoration(
color: message.mine ? cs.primary : cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: Text(
message.text,
style: TextStyle(
color: message.mine ? cs.onPrimary : cs.onSurface,
fontSize: 15,
height: 1.25,
),
),
),
);
}
Widget _input(ColorScheme cs) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: _controller,
minLines: 1,
maxLines: 4,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
style: TextStyle(color: cs.onSurface, fontSize: 15),
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.hubChatInputHint,
hintStyle: TextStyle(color: cs.onSurfaceVariant),
filled: true,
fillColor: cs.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 11,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
),
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: _send,
icon: const Icon(Symbols.send, fill: 1),
),
],
),
);
}
}
class _CheckersView extends StatefulWidget {
final CallSession session;
const _CheckersView({required this.session});
@override
State<_CheckersView> createState() => _CheckersViewState();
}
class _CheckersViewState extends State<_CheckersView> {
List<int> _board = Checkers.initial();
CheckersSide _turn = CheckersSide.white;
List<int> _path = const [];
List<List<int>> _legal = const [];
CheckersSide? _result;
late final CheckersSide _me;
StreamSubscription<Map<String, dynamic>>? _sub;
@override
void initState() {
super.initState();
final self = widget.session.selfUserId;
final peer = widget.session.peerUserId ?? (self + 1);
_me = self <= peer ? CheckersSide.white : CheckersSide.black;
_recompute();
_sub = widget.session.gameMessages.listen(_onGame);
}
void _recompute() {
_legal = Checkers.legalMoves(_board, _turn);
_result = _legal.isEmpty ? Checkers.opponent(_turn) : null;
}
void _restart() {
_board = Checkers.initial();
_turn = CheckersSide.white;
_path = const [];
_recompute();
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
void _onGame(Map<String, dynamic> data) {
if (data['g'] != 'checkers') return;
if (data['a'] == 'reset') {
setState(_restart);
return;
}
if (data['a'] == 'move') {
final raw = data['path'];
if (raw is! List) return;
_applyMove(raw.map((e) => e as int).toList(), fromRemote: true);
}
}
void _applyMove(List<int> path, {required bool fromRemote}) {
if (!_legal.any((p) => _listEq(p, path))) return;
setState(() {
_board = Checkers.applyMove(_board, path);
_turn = Checkers.opponent(_turn);
_path = const [];
_recompute();
});
if (!fromRemote) {
widget.session.sendGame({'g': 'checkers', 'a': 'move', 'path': path});
}
}
void _reset() {
setState(_restart);
widget.session.sendGame({'g': 'checkers', 'a': 'reset'});
}
void _onTap(int square) {
if (_turn != _me || _result != null) return;
if (_path.isEmpty) {
if (_legal.any((p) => p.first == square)) {
setState(() => _path = [square]);
}
return;
}
final prefix = [..._path, square];
final matching = _legal.where((p) => _startsWith(p, prefix)).toList();
if (matching.isEmpty) {
setState(
() =>
_path = _legal.any((p) => p.first == square) ? [square] : const [],
);
return;
}
if (matching.any((p) => p.length == prefix.length)) {
_applyMove(prefix, fromRemote: false);
} else {
setState(() => _path = prefix);
}
}
Set<int> _options() {
if (_turn != _me || _result != null) return const {};
if (_path.isEmpty) return {for (final p in _legal) p.first};
final next = <int>{};
for (final p in _legal) {
if (_startsWith(p, _path) && p.length > _path.length) {
next.add(p[_path.length]);
}
}
return next;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final cs = Theme.of(context).colorScheme;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
_status(),
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
TextButton.icon(
onPressed: _reset,
icon: const Icon(Symbols.refresh, size: 20),
label: Text(l10n.hubCheckersRestart),
),
],
),
const SizedBox(height: 8),
_boardWidget(cs),
const SizedBox(height: 10),
Text(
_me == CheckersSide.white
? l10n.hubCheckersYouWhite
: l10n.hubCheckersYouBlack,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
],
),
);
}
String _status() {
final l10n = AppLocalizations.of(context)!;
final w = _result;
if (w != null) return w == _me ? l10n.hubCheckersWon : l10n.hubCheckersLost;
return _turn == _me
? l10n.hubCheckersYourMove
: l10n.hubCheckersOpponentMove;
}
Widget _boardWidget(ColorScheme cs) {
final flip = _me == CheckersSide.black;
final options = _options();
final selected = _path.isNotEmpty ? _path.last : -1;
return AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GridView.builder(
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: Checkers.size,
),
itemCount: Checkers.size * Checkers.size,
itemBuilder: (_, i) {
final square = flip ? (Checkers.size * Checkers.size - 1 - i) : i;
final rb = square ~/ Checkers.size;
final cb = square % Checkers.size;
final dark = (rb + cb) % 2 == 1;
return GestureDetector(
onTap: dark ? () => _onTap(square) : null,
child: _cell(
cs,
dark: dark,
piece: _board[square],
option: options.contains(square),
selected: square == selected,
),
);
},
),
),
);
}
Widget _cell(
ColorScheme cs, {
required bool dark,
required int piece,
required bool option,
required bool selected,
}) {
final base = dark ? cs.surfaceContainerHighest : cs.surfaceContainerLow;
return Container(
color: selected ? cs.primary.withValues(alpha: 0.45) : base,
child: Stack(
alignment: Alignment.center,
children: [
if (option && piece == Checkers.empty)
FractionallySizedBox(
widthFactor: 0.34,
heightFactor: 0.34,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: cs.primary.withValues(alpha: 0.55),
),
),
),
if (piece != Checkers.empty) _piece(cs, piece, option),
],
),
);
}
Widget _piece(ColorScheme cs, int piece, bool option) {
final white = Checkers.sideOf(piece) == CheckersSide.white;
final king = Checkers.isKing(piece);
return FractionallySizedBox(
widthFactor: 0.76,
heightFactor: 0.76,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: white ? const Color(0xFFEDEDED) : const Color(0xFF262626),
border: Border.all(
color: option
? cs.primary
: (white ? const Color(0xFFB8B8B8) : const Color(0xFF050505)),
width: option ? 2.5 : 1.5,
),
boxShadow: const [
BoxShadow(
color: Colors.black38,
blurRadius: 3,
offset: Offset(0, 1),
),
],
),
child: king
? Icon(
Symbols.star,
fill: 1,
size: 16,
color: white
? const Color(0xFF8A6D00)
: const Color(0xFFE7C200),
)
: null,
),
);
}
bool _startsWith(List<int> path, List<int> prefix) {
if (path.length < prefix.length) return false;
for (var i = 0; i < prefix.length; i++) {
if (path[i] != prefix[i]) return false;
}
return true;
}
bool _listEq(List<int> a, List<int> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
}