2026-09-19 15:43:37 +02:00
using UIKit ;
2026-09-19 19:33:10 +02:00
using VoiceCat.Audio ;
2026-09-19 15:43:37 +02:00
using Voicecat.V1 ;
using Channel = Voicecat . V1 . Channel ;
namespace VoiceCat.iOS ;
internal sealed class MainTabController : UITabBarController
{
internal MainTabController ( AppModel model )
{
2026-09-19 19:33:10 +02:00
UIViewController channels = Wrap ( new ChannelsController ( model ), "Channels" , "list.bullet.indent" , 0 );
UIViewController chat = Wrap ( new ChatController ( model ), "Chat" , "message" , 1 );
UIViewController settings = Wrap ( new SettingsController ( model ), "Settings" , "gear" , 2 );
2026-09-19 15:43:37 +02:00
ViewControllers = [ channels , chat , settings ];
}
2026-09-19 19:33:10 +02:00
private static UIViewController Wrap ( UIViewController content , string title , string image , nint tag )
{
var root = new UIViewController (); UIView rootView = root . View !; rootView . BackgroundColor = UIColor . SystemBackground ;
var navigation = new UINavigationController ( content ); UIView navigationView = navigation . View !; navigationView . TranslatesAutoresizingMaskIntoConstraints = false ;
root . AddChildViewController ( navigation ); rootView . AddSubview ( navigationView ); navigation . DidMoveToParentViewController ( root );
var voice = new VoiceControlsView ( AppModel . Shared ) { TranslatesAutoresizingMaskIntoConstraints = false }; rootView . AddSubview ( voice );
NSLayoutConstraint . ActivateConstraints ([ navigationView . TopAnchor . ConstraintEqualTo ( rootView . TopAnchor ), navigationView . LeadingAnchor . ConstraintEqualTo ( rootView . LeadingAnchor ),
navigationView . TrailingAnchor . ConstraintEqualTo ( rootView . TrailingAnchor ), navigationView . BottomAnchor . ConstraintEqualTo ( voice . TopAnchor ),
voice . LeadingAnchor . ConstraintEqualTo ( rootView . LeadingAnchor ), voice . TrailingAnchor . ConstraintEqualTo ( rootView . TrailingAnchor ),
voice . BottomAnchor . ConstraintEqualTo ( rootView . SafeAreaLayoutGuide . BottomAnchor ), voice . HeightAnchor . ConstraintEqualTo ( 58 )]);
root . TabBarItem = new ( title , UIImage . GetSystemImage ( image ), tag ); return root ;
}
}
internal sealed class VoiceControlsView : UIView
{
private readonly AppModel model ; private readonly UIButton join = UIButton . FromType ( UIButtonType . System );
private readonly UIButton ptt = UIButton . FromType ( UIButtonType . System ); private readonly UIButton mute = UIButton . FromType ( UIButtonType . System );
private readonly UIButton deafen = UIButton . FromType ( UIButtonType . System ); private readonly UIProgressView level = new ( UIProgressViewStyle . Default );
internal VoiceControlsView ( AppModel model ) { this . model = model ; model . Changed += Refresh ; Build (); }
private void Build ()
{
BackgroundColor = UIColor . SecondarySystemBackground ; join . TouchUpInside += async ( _ , _ ) => await Run ( model . ToggleVoiceAsync );
ptt . TouchDown += ( _ , _ ) => model . SetPushToTalk ( true ); ptt . TouchUpInside += ( _ , _ ) => model . SetPushToTalk ( false );
ptt . TouchUpOutside += ( _ , _ ) => model . SetPushToTalk ( false ); ptt . TouchCancel += ( _ , _ ) => model . SetPushToTalk ( false );
mute . TouchUpInside += ( _ , _ ) => model . SetSelfAudio (!( model . Client ?. Audio . MicMuted ?? false ), model . Client ?. Audio . Deafened ?? false );
deafen . TouchUpInside += ( _ , _ ) => model . SetSelfAudio ( model . Client ?. Audio . MicMuted ?? false , !( model . Client ?. Audio . Deafened ?? false ));
UIStackView stack = new ([ join , ptt , level , mute , deafen ]) { Axis = UILayoutConstraintAxis . Horizontal , Alignment = UIStackViewAlignment . Center ,
Distribution = UIStackViewDistribution . Fill , Spacing = 12 , TranslatesAutoresizingMaskIntoConstraints = false };
AddSubview ( stack ); level . WidthAnchor . ConstraintEqualTo ( 65 ). Active = true ;
NSLayoutConstraint . ActivateConstraints ([ stack . LeadingAnchor . ConstraintEqualTo ( LeadingAnchor , 12 ), stack . TrailingAnchor . ConstraintEqualTo ( TrailingAnchor , - 12 ), stack . CenterYAnchor . ConstraintEqualTo ( CenterYAnchor )]); Refresh ();
}
private void Refresh ()
{
join . SetTitle ( model . VoiceJoined ? "Leave Voice" : "Join Voice" , UIControlState . Normal ); join . AccessibilityLabel = join . Title ( UIControlState . Normal ); join . Enabled = model . CurrentChannelId != 0 ;
ptt . SetTitle ( "Hold to Talk" , UIControlState . Normal ); ptt . AccessibilityLabel = "Push to talk, hold to transmit" ; ptt . Hidden = model . Settings . InputMode != AudioInputMode . PushToTalk ; ptt . Enabled = model . VoiceJoined ;
bool muted = model . Client ?. Audio . MicMuted == true , deafened = model . Client ?. Audio . Deafened == true ;
mute . SetImage ( UIImage . GetSystemImage ( muted ? "mic.slash.fill" : "mic.fill" ), UIControlState . Normal ); mute . AccessibilityLabel = muted ? "Unmute microphone" : "Mute microphone" ; mute . Enabled = model . VoiceJoined ;
deafen . SetImage ( UIImage . GetSystemImage ( deafened ? "headphones.slash" : "headphones" ), UIControlState . Normal ); deafen . AccessibilityLabel = deafened ? "Undeafen" : "Deafen" ; deafen . Enabled = model . VoiceJoined ;
level . Progress = Math . Clamp ( model . MicrophoneLevel * 10 , 0 , 1 ); level . AccessibilityLabel = "Microphone level" ; level . AccessibilityValue = $"{level.Progress:P0}" ;
}
private async Task Run ( Func < Task > operation ) { try { await operation (); } catch ( Exception exception ) { if ( Window ?. RootViewController is { } owner ) UiHelpers . ShowError ( owner , exception ); } }
2026-09-19 15:43:37 +02:00
}
internal sealed class ChannelsController : UITableViewController
{
2026-09-19 19:33:10 +02:00
private readonly AppModel model ; private IReadOnlyList <( Channel Channel , int Depth )> Visible => Flatten ();
internal ChannelsController ( AppModel model ) { this . model = model ; Title = "Channels" ; model . Changed += Reload ; }
public override void ViewDidLoad ()
2026-09-19 15:43:37 +02:00
{
2026-09-19 19:33:10 +02:00
base . ViewDidLoad (); TableView . RegisterClassForCellReuse ( typeof ( UITableViewCell ), "channel" );
NavigationItem . RightBarButtonItems = [ new ( "Users" , UIBarButtonItemStyle . Plain , ( _ , _ ) => NavigationController ?. PushViewController ( new UsersController ( model ), true )),
new ( UIBarButtonSystemItem . Add , ( _ , _ ) => NavigationController ?. PushViewController ( new ChannelEditorController ( model , null ), true ))]; Reload ();
2026-09-19 15:43:37 +02:00
}
2026-09-19 19:33:10 +02:00
private void Reload () { TableView . ReloadData (); NavigationItem . RightBarButtonItems ![ 1 ]. Enabled = model . Client ?. Permissions is { } p && ( p . IsAdmin || p . CanCreateTempChannel ); }
2026-09-19 15:43:37 +02:00
public override nint RowsInSection ( UITableView tableView , nint section ) => Visible . Count ;
public override UITableViewCell GetCell ( UITableView tableView , NSIndexPath indexPath )
{
2026-09-19 19:33:10 +02:00
( Channel channel , int depth ) = Visible [ indexPath . Row ]; UITableViewCell cell = tableView . DequeueReusableCell ( "channel" , indexPath );
int count = model . Users . Count ( user => user . ChannelId == channel . Id ); var content = cell . DefaultContentConfiguration ;
content . Text = new string ( ' ' , depth * 3 ) + channel . Name ; content . SecondaryText = string . IsNullOrEmpty ( channel . Topic ) ? $"{count} users" : $"{channel.Topic} • {count} users" ;
content . Image = UIImage . GetSystemImage ( channel . Id == model . CurrentChannelId ? "checkmark.circle.fill" : channel . PasswordProtected ? "lock.fill" : "bubble.left" ); cell . ContentConfiguration = content ;
cell . AccessibilityLabel = $"{channel.Name}{(channel.Id == model.CurrentChannelId ? " , current " : "")}{(channel.PasswordProtected ? " , password protected " : "")}, {count} users" ; return cell ;
2026-09-19 15:43:37 +02:00
}
2026-09-19 19:33:10 +02:00
public override async void RowSelected ( UITableView tableView , NSIndexPath indexPath )
2026-09-19 15:43:37 +02:00
{
2026-09-19 19:33:10 +02:00
Channel channel = Visible [ indexPath . Row ]. Channel ; tableView . DeselectRow ( indexPath , true );
if ( channel . PasswordProtected ) { PromptPassword ( channel ); return ; }
try { await model . JoinChannelAsync ( channel . Id ); } catch ( Exception exception ) { UiHelpers . ShowError ( this , exception ); }
}
public override UISwipeActionsConfiguration ? GetTrailingSwipeActionsConfiguration ( UITableView tableView , NSIndexPath indexPath )
{
if ( model . Client ?. Permissions . IsAdmin != true ) return null ; Channel channel = Visible [ indexPath . Row ]. Channel ;
UIContextualAction edit = UIContextualAction . FromContextualActionStyle ( UIContextualActionStyle . Normal , "Edit" , ( _ , _ , done ) => { NavigationController ?. PushViewController ( new ChannelEditorController ( model , channel ), true ); done ( true ); });
UIContextualAction delete = UIContextualAction . FromContextualActionStyle ( UIContextualActionStyle . Destructive , "Delete" , ( _ , _ , done ) => { ConfirmDelete ( channel ); done ( true ); });
delete . BackgroundColor = UIColor . SystemRed ; return UISwipeActionsConfiguration . FromActions ( channel . Id == 1 ? [ edit ] : [ delete , edit ]);
}
private void PromptPassword ( Channel channel )
{
UIAlertController prompt = UIAlertController . Create ( "Channel Password" , channel . Name , UIAlertControllerStyle . Alert ); prompt . AddTextField ( field => { field . SecureTextEntry = true ; field . AccessibilityLabel = "Channel password" ; });
prompt . AddAction ( UIAlertAction . Create ( "Cancel" , UIAlertActionStyle . Cancel , null )); prompt . AddAction ( UIAlertAction . Create ( "Join" , UIAlertActionStyle . Default , async _ => { try { await model . JoinChannelAsync ( channel . Id , prompt . TextFields ?[ 0 ]. Text ?? "" ); } catch ( Exception exception ) { UiHelpers . ShowError ( this , exception ); } })); PresentViewController ( prompt , true , null );
}
private void ConfirmDelete ( Channel channel )
{
UIAlertController alert = UIAlertController . Create ( "Delete channel?" , channel . Name , UIAlertControllerStyle . Alert ); alert . AddAction ( UIAlertAction . Create ( "Cancel" , UIAlertActionStyle . Cancel , null ));
alert . AddAction ( UIAlertAction . Create ( "Delete" , UIAlertActionStyle . Destructive , async _ => { try { GenericResult result = await model . RunAdminAsync ( client => client . DeleteChannelAsync ( channel . Id )); if (! result . Ok ) throw new InvalidOperationException ( result . Message ); } catch ( Exception exception ) { UiHelpers . ShowError ( this , exception ); } })); PresentViewController ( alert , true , null );
}
private IReadOnlyList <( Channel , int )> Flatten ()
{
var result = new List <( Channel , int )>(); void Add ( uint parent , int depth ) { foreach ( Channel value in model . Channels . Where ( channel => channel . ParentId == parent ). OrderBy ( channel => channel . Order ). ThenBy ( channel => channel . Name )) { result . Add (( value , depth )); Add ( value . Id , depth + 1 ); } }
Add ( 0 , 0 ); return result ;
2026-09-19 15:43:37 +02:00
}
}
internal sealed class ChatController : UIViewController
{
private readonly AppModel model ; private readonly UITextView log = new (); private readonly UITextField compose = UiHelpers . Field ( "Message" );
internal ChatController ( AppModel model ) { this . model = model ; Title = "Chat" ; model . Changed += Refresh ; }
public override void ViewDidLoad ()
{
2026-09-19 19:33:10 +02:00
base . ViewDidLoad (); View !. BackgroundColor = UIColor . SystemBackground ; log . Editable = false ; log . Font = UIFont . PreferredBody ; log . AccessibilityLabel = "Chat and activity timeline" ; log . TranslatesAutoresizingMaskIntoConstraints = false ;
2026-09-19 15:43:37 +02:00
UIButton send = UIButton . FromType ( UIButtonType . System ); send . SetTitle ( "Send" , UIControlState . Normal ); send . AccessibilityLabel = "Send message" ; send . TranslatesAutoresizingMaskIntoConstraints = false ; send . TouchUpInside += ( _ , _ ) => { model . SendText ( compose . Text ?? "" ); compose . Text = "" ; };
compose . TranslatesAutoresizingMaskIntoConstraints = false ; View . AddSubviews ( log , compose , send );
NSLayoutConstraint . ActivateConstraints ([ log . TopAnchor . ConstraintEqualTo ( View . SafeAreaLayoutGuide . TopAnchor ), log . LeadingAnchor . ConstraintEqualTo ( View . LeadingAnchor , 12 ), log . TrailingAnchor . ConstraintEqualTo ( View . TrailingAnchor , - 12 ), compose . TopAnchor . ConstraintEqualTo ( log . BottomAnchor , 8 ), compose . LeadingAnchor . ConstraintEqualTo ( View . LeadingAnchor , 12 ), compose . BottomAnchor . ConstraintEqualTo ( View . KeyboardLayoutGuide . TopAnchor , - 8 ), send . LeadingAnchor . ConstraintEqualTo ( compose . TrailingAnchor , 8 ), send . TrailingAnchor . ConstraintEqualTo ( View . TrailingAnchor , - 12 ), send . CenterYAnchor . ConstraintEqualTo ( compose . CenterYAnchor ), compose . WidthAnchor . ConstraintGreaterThanOrEqualTo ( 120 )]); Refresh ();
}
2026-09-19 19:33:10 +02:00
private void Refresh ()
2026-09-19 15:43:37 +02:00
{
2026-09-19 19:33:10 +02:00
IEnumerable <( DateTime Time , string Text )> chat = model . Messages . Select ( message => ( message . Timestamp , $"{(message.Private ? " [ private ] " : "")}{message.Sender}: {message.Text}" ));
IEnumerable <( DateTime Time , string Text )> activity = model . Activity . Select ( value => ( value . Timestamp , $"• {value.Text}" ));
log . Text = string . Join ( "\n" , chat . Concat ( activity ). OrderBy ( value => value . Time ). Select ( value => $"[{value.Time:t}] {value.Text}" )); if ( log . Text . Length > 0 ) log . ScrollRangeToVisible ( new ( log . Text . Length - 1 , 1 ));
2026-09-19 15:43:37 +02:00
}
}